From 77dc8f8eadbe2eb5cfdfac5ae2e261ca494670dd Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 5 Aug 2026 19:03:13 +0700 Subject: [PATCH] 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 --- Cargo.lock | 1 - crates/benilla-formats/src/dbc.rs | 38 + crates/benilla-formats/src/lib.rs | 1 + crates/benilla-formats/src/light.rs | 54 +- .../benilla-formats/src/spell_visual/mod.rs | 50 + .../benilla-formats/src/spell_visual/tests.rs | 121 +++ .../tests/light_no_row_fallback.rs | 137 +++ crates/benilla-protocol/src/events.rs | 20 +- crates/benilla-protocol/src/events/decode.rs | 12 + crates/benilla-protocol/src/messages/mod.rs | 9 + .../benilla-protocol/src/messages/opcode.rs | 28 + .../benilla-protocol/src/messages/packet.rs | 35 +- crates/benilla-protocol/src/messages/parse.rs | 15 +- crates/benilla-protocol/src/messages/pet.rs | 668 +++++++++++++ .../src/messages/update_object/fields/mod.rs | 6 + .../src/messages/update_object/fields/unit.rs | 6 + .../benilla-protocol/src/world/writer/mod.rs | 2 + .../benilla-protocol/src/world/writer/pet.rs | 54 + crates/benilla-ui/src/loader/regions.rs | 20 +- crates/benilla-ui/src/loader/tests.rs | 81 -- crates/benilla-ui/src/loader/widgets.rs | 37 +- crates/benilla-ui/src/script/action.rs | 3 +- crates/benilla-ui/src/script/container.rs | 4 +- crates/benilla-ui/src/script/cursor.rs | 28 +- crates/benilla-ui/src/script/cursor/bar.rs | 7 +- crates/benilla-ui/src/script/cursor/doll.rs | 4 +- .../benilla-ui/src/script/era_atlas_tests.rs | 127 --- crates/benilla-ui/src/script/macros.rs | 742 ++++++++++++++ crates/benilla-ui/src/script/mod.rs | 39 +- crates/benilla-ui/src/script/model.rs | 50 +- crates/benilla-ui/src/script/pet.rs | 432 ++++++++ crates/benilla-ui/src/script/pointer.rs | 34 +- crates/benilla-ui/src/script/region.rs | 52 - crates/benilla-ui/src/script/slider.rs | 56 +- crates/benilla-ui/src/script/spellbook.rs | 104 ++ crates/benilla-ui/src/script/tests/slider.rs | 47 +- crates/benilla-ui/src/script/tooltip_spell.rs | 26 + crates/benilla-ui/src/script/types.rs | 16 - crates/benilla/Cargo.toml | 6 +- crates/benilla/assets/ui/GameMenuFrame.xml | 21 +- crates/benilla/assets/ui/MacroFrame.xml | 933 ++++++++++++++++++ crates/benilla/assets/ui/OptionsFrame.xml | 873 ++++++++++------ crates/benilla/assets/ui/PetActionBar.xml | 435 ++++++++ crates/benilla/assets/ui/SpellBookFrame.xml | 21 +- crates/benilla/assets/ui/UIParent.xml | 66 +- crates/benilla/assets/ui/UiPanels.xml | 5 + crates/benilla/src/assets/era.rs | 87 -- crates/benilla/src/assets/mod.rs | 20 - crates/benilla/src/capture/fixtures.rs | 18 + crates/benilla/src/capture/scenarios.rs | 45 + crates/benilla/src/cooldowns.rs | 37 + crates/benilla/src/creature_anim.rs | 7 +- crates/benilla/src/creature_anim/driver.rs | 46 +- .../benilla/src/creature_anim/driver/mode.rs | 40 +- .../benilla/src/creature_anim/driver/tests.rs | 134 +++ crates/benilla/src/creature_anim/select.rs | 33 + .../benilla/src/creature_anim/select/tests.rs | 30 + .../benilla/src/creature_anim/spell_visual.rs | 57 +- .../src/creature_anim/spell_visual/tests.rs | 52 +- crates/benilla/src/cursor.rs | 1 + crates/benilla/src/entities/attach/mod.rs | 3 + .../benilla/src/entities/equipment/spawn.rs | 3 + crates/benilla/src/entities/spell_fx/mod.rs | 6 + crates/benilla/src/entities/wmo_props.rs | 3 + crates/benilla/src/local_state.rs | 45 + crates/benilla/src/main.rs | 12 + crates/benilla/src/net.rs | 27 + crates/benilla/src/net/apply.rs | 80 +- crates/benilla/src/net/apply/pet.rs | 232 +++++ crates/benilla/src/net/apply/spells.rs | 76 +- crates/benilla/src/net/io.rs | 9 + crates/benilla/src/particles.rs | 28 +- crates/benilla/src/particles/sim.rs | 109 +- crates/benilla/src/portrait/booth.rs | 3 + crates/benilla/src/portrait/glue_booth.rs | 3 + crates/benilla/src/sound/ui.rs | 5 +- crates/benilla/src/ui_action/drain.rs | 22 +- crates/benilla/src/ui_action/feed.rs | 29 +- crates/benilla/src/ui_action/feed_tests.rs | 79 ++ crates/benilla/src/ui_action/state.rs | 74 +- crates/benilla/src/ui_chat.rs | 2 +- crates/benilla/src/ui_chat/commands.rs | 26 +- crates/benilla/src/ui_chat/input/mod.rs | 19 + crates/benilla/src/ui_chat/input/parse.rs | 24 + crates/benilla/src/ui_chat/tests.rs | 39 +- crates/benilla/src/ui_macro/mod.rs | 239 +++++ crates/benilla/src/ui_macro/run.rs | 204 ++++ crates/benilla/src/ui_macro/store.rs | 228 +++++ crates/benilla/src/ui_macro/tests.rs | 148 +++ crates/benilla/src/ui_pet.rs | 812 +++++++++++++++ crates/benilla/src/ui_script/extract/mod.rs | 1 + .../benilla/src/ui_script/game_menu_tests.rs | 9 +- crates/benilla/src/ui_script/macro_tests.rs | 330 +++++++ crates/benilla/src/ui_script/mod.rs | 53 +- crates/benilla/src/ui_script/options_tests.rs | 426 ++++++-- crates/benilla/src/ui_script/pet_bar_tests.rs | 362 +++++++ crates/benilla/src/ui_text/layout/mod.rs | 59 +- crates/benilla/src/wmo_portal/audit/mod.rs | 49 + 98 files changed, 8922 insertions(+), 1089 deletions(-) create mode 100644 crates/benilla-formats/tests/light_no_row_fallback.rs create mode 100644 crates/benilla-protocol/src/messages/pet.rs create mode 100644 crates/benilla-protocol/src/world/writer/pet.rs delete mode 100644 crates/benilla-ui/src/script/era_atlas_tests.rs create mode 100644 crates/benilla-ui/src/script/macros.rs create mode 100644 crates/benilla-ui/src/script/pet.rs create mode 100644 crates/benilla/assets/ui/MacroFrame.xml create mode 100644 crates/benilla/assets/ui/PetActionBar.xml delete mode 100644 crates/benilla/src/assets/era.rs create mode 100644 crates/benilla/src/net/apply/pet.rs create mode 100644 crates/benilla/src/ui_macro/mod.rs create mode 100644 crates/benilla/src/ui_macro/run.rs create mode 100644 crates/benilla/src/ui_macro/store.rs create mode 100644 crates/benilla/src/ui_macro/tests.rs create mode 100644 crates/benilla/src/ui_pet.rs create mode 100644 crates/benilla/src/ui_script/macro_tests.rs create mode 100644 crates/benilla/src/ui_script/pet_bar_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 868825d4..67ad2012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,7 +595,6 @@ dependencies = [ "rand 0.8.6", "raw-window-handle", "serde", - "serde_json", "smithay-clipboard", "toml", ] diff --git a/crates/benilla-formats/src/dbc.rs b/crates/benilla-formats/src/dbc.rs index d3fffe64..180fdaf0 100644 --- a/crates/benilla-formats/src/dbc.rs +++ b/crates/benilla-formats/src/dbc.rs @@ -63,6 +63,44 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option { } } +/// 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> { + 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`] diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 9f16b4e6..b7f9b42c 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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}; diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index 32fadb9e..73605fe4 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -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 diff --git a/crates/benilla-formats/src/spell_visual/mod.rs b/crates/benilla-formats/src/spell_visual/mod.rs index cfc35336..ae1d7d00 100644 --- a/crates/benilla-formats/src/spell_visual/mod.rs +++ b/crates/benilla-formats/src/spell_visual/mod.rs @@ -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). diff --git a/crates/benilla-formats/src/spell_visual/tests.rs b/crates/benilla-formats/src/spell_visual/tests.rs index a070aece..89ae1e74 100644 --- a/crates/benilla-formats/src/spell_visual/tests.rs +++ b/crates/benilla-formats/src/spell_visual/tests.rs @@ -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"); +} diff --git a/crates/benilla-formats/tests/light_no_row_fallback.rs b/crates/benilla-formats/tests/light_no_row_fallback.rs new file mode 100644 index 00000000..5524c886 --- /dev/null +++ b/crates/benilla-formats/tests/light_no_row_fallback.rs @@ -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" + ); +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 3ae14098..967272e4 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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, }, + /// 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), + /// 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 }, /// 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 diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 9c1e1bb0..4adfaa61 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -75,6 +75,18 @@ pub fn decode(packet: ServerPacket) -> Vec { 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 }] } diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 513619e5..40574bbe 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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::{ diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 22a6cc10..f068e073 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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`. diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 9cec7eff..c11e03b9 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index f9246903..23d46829 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -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 { 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 } diff --git a/crates/benilla-protocol/src/messages/pet.rs b/crates/benilla-protocol/src/messages/pet.rs new file mode 100644 index 00000000..14391c43 --- /dev/null +++ b/crates/benilla-protocol/src/messages/pet.rs @@ -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 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, + /// The pet's running cooldowns. + pub cooldowns: Vec, +} + +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 +/// 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 5251ca89..f255c1d1 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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`). diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs index 164ccbb5..3d93a7d8 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs @@ -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 { + 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 { diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index 3c4e04c0..05509373 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/world/writer/pet.rs b/crates/benilla-protocol/src/world/writer/pet.rs new file mode 100644 index 00000000..3440723b --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/pet.rs @@ -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), + ) + } +} diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 16929fa5..f1f3c8b2 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -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 is a tint (vertex color). diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index 5b6d5c95..a0f81c8b 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -1000,85 +1000,4 @@ mod region_template_tests { ); assert_eq!(s.eval::("return Label:GetText()").unwrap(), "hello"); } - - /// `atlas=` on a Button/CheckButton state texture and on a Slider `` — 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=/. - #[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#" - - - - - - - - - - - - "#, - ) - .expect("valid FrameXML"); - let report = load(&s, &doc, &no_files); - assert!(report.errors.is_empty(), "errors: {:?}", report.errors); - assert_eq!( - s.eval::("return Box:GetNormalTexture():GetAtlas()") - .unwrap(), - "checkbox-minimal" - ); - assert_eq!( - s.eval::("return Box:GetCheckedTexture():GetAtlas()") - .unwrap(), - "checkmark-minimal" - ); - assert_eq!( - s.eval::("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::("return Bar:GetThumbTexture():GetWidth()") - .unwrap(), - 20.0 - ); - assert_eq!( - s.eval::("return Bar:GetThumbTexture():GetHeight()") - .unwrap(), - 19.0 - ); - } } diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index 75534644..3b854dff 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -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 `` - // texture's): the byte-verified 1.12 table knows only file=/, 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 — `` 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 / / / 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::(getter.as_str(), ()) { - if let Some(atlas) = t.attr("atlas") { - this.call_region( - ®ion, - "SetAtlas", - (atlas.to_string(), t.attr_bool("useAtlasSize")), - dbg, - ); - } if let Some(mode) = t.attr("alphaMode") { this.call_region(®ion, "SetBlendMode", mode.to_string(), dbg); } @@ -338,28 +334,11 @@ impl Loader<'_> { ); } else if let Some(c) = children_named(tt, "Color").next().map(color_of) { self.call(wrapper, "SetThumbTexture", (c[0], c[1], c[2], c[3]), dbg); - } else if tt.attr("atlas").is_some() { - // `atlas=` — the Era dialect (0950), same posture as the Button state textures - // above: create the slot empty, resolve via SetAtlas below. - self.call( - wrapper, - "SetThumbTexture", - (String::new(), layer.clone()), - dbg, - ); } // alphaMode / / / 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::
("GetThumbTexture", ()) { - if let Some(atlas) = tt.attr("atlas") { - self.call_region( - ®ion, - "SetAtlas", - (atlas.to_string(), tt.attr_bool("useAtlasSize")), - dbg, - ); - } if let Some(mode) = tt.attr("alphaMode") { self.call_region(®ion, "SetBlendMode", mode.to_string(), dbg); } diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index 7c166c26..38d5e11b 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -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, diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 915d3bee..71242cdf 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -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 } diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 8d3e90d9..e6a87c99 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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, } +/// 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, +} + // ───────────────────────────────────────────────────────────────────────────────────────────── // 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)), } })?, diff --git a/crates/benilla-ui/src/script/cursor/bar.rs b/crates/benilla-ui/src/script/cursor/bar.rs index e54f76ca..670cafc6 100644 --- a/crates/benilla-ui/src/script/cursor/bar.rs +++ b/crates/benilla-ui/src/script/cursor/bar.rs @@ -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, diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index 7b35d441..482c2133 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -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 } diff --git a/crates/benilla-ui/src/script/era_atlas_tests.rs b/crates/benilla-ui/src/script/era_atlas_tests.rs deleted file mode 100644 index a279165e..00000000 --- a/crates/benilla-ui/src/script/era_atlas_tests.rs +++ /dev/null @@ -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#" - - - - - - - - "#, - ) - .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::("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::("return TInner:GetWidth()").unwrap(), 886.0); - assert_eq!(s.eval::("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#" - - - - - - - - "#, - ) - .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::("return TTex:GetAtlas()").unwrap(), - "checkbox-minimal" - ); - assert_eq!(s.eval::("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::("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#" - - - - - - - - "#, - ) - .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::("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()); -} diff --git a/crates/benilla-ui/src/script/macros.rs b/crates/benilla-ui/src/script/macros.rs new file mode 100644 index 00000000..a573f0ea --- /dev/null +++ b/crates/benilla-ui/src/script/macros.rs @@ -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, + /// 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, + /// Indices 19..=36. + pub character: Vec, +} + +/// 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 { + 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 { + if per_character { + &self.character + } else { + &self.account + } + } + + fn list_mut(&mut self, per_character: bool) -> &mut Vec { + 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) { + 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, + body: &str, + local_only: bool, + per_character: bool, +) -> Option { + 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, + texture: Option>, + body: Option, + local_only: Option, +) -> Option { + 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 { + 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::().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::().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::().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::().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::().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 = args.into_iter().collect(); + let name = opt_string(a.first()).unwrap_or_default(); + let mut model = lua.app_data_mut::().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 = 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::().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::().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::().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 { + 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::(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::(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::(r#"return GetMacroIndexByName("ambush")"#) + .unwrap(), + 1 + ); + assert_eq!( + s.eval::(r#"return GetMacroIndexByName("bolt")"#) + .unwrap(), + 19 + ); + assert_eq!( + s.eval::(r#"return GetMacroIndexByName("nope")"#) + .unwrap(), + 0 + ); + + // Delete closes the gap: the character macro keeps its own base. + assert!(s.eval::("return DeleteMacro(1)").unwrap()); + assert_eq!( + s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c") + .unwrap(), + (0, 1) + ); + assert!(s.eval::("return GetMacroInfo(1) == nil").unwrap()); + assert_eq!(s.eval::("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::(r#"return CreateMacro("", 1, "x") == nil"#) + .unwrap()); + assert!(s + .eval::(r#"return CreateMacro(" ", 1, "x") == nil"#) + .unwrap()); + + for i in 1..=MAX_MACROS { + let made = s + .eval::(&format!(r#"return CreateMacro("m{i}", 1, "")"#)) + .unwrap(); + assert_eq!(made as usize, i); + } + assert!( + s.eval::(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::(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::("return PickupMacro(5)").unwrap(), + "empty slot" + ); + assert!(s.eval::("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::("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::("return GetNumMacroIcons()").unwrap(), 3); + assert_eq!( + s.eval::("return GetMacroIconInfo(2)").unwrap(), + "Interface\\Icons\\Ability_BackStab" + ); + assert!(s.eval::("return GetMacroIconInfo(4) == nil").unwrap()); + assert!(s.eval::("return GetMacroIconInfo(0) == nil").unwrap()); + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 6ec823bd..e00ebc4f 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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) { - 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 { - let mut misses: Vec = 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 / diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 6bc5e0c7..9c2a1034 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, - /// 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, - /// 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, /// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `` 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, /// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`] /// drain. pub(crate) spell_casts: Vec, @@ -281,6 +288,19 @@ pub(crate) struct Model { /// [`super::UiScript::take_shapeshift_casts`] drain. pub(crate) shapeshift_casts: Vec, + /// 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, + /// 1-based slot indices `TogglePetAutocast` queued. + pub(crate) pet_autocast_toggles: Vec, + /// `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, @@ -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(), diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs new file mode 100644 index 00000000..4534a28f --- /dev/null +++ b/crates/benilla-ui/src/script/pet.rs @@ -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, + /// The second return — the spell's rank line, or `None` for a token. + pub subtext: Option, + /// 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, + /// 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, + /// 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, +} + +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, + ) { + 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 { + 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 { + 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::().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::().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::().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| 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::().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::().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::().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::().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::().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 { + 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::("return PetHasActionBar() == nil").unwrap()); + assert!(s.eval::("return GetPetActionInfo(1) == nil").unwrap()); + + s.set_pet_actions(true, true, slots()); + assert_eq!(s.eval::("return PetHasActionBar()").unwrap(), 1); + assert_eq!(s.eval::("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, + Option, + Option, + Option, + Option, + )>("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::( + "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::("local n, _, tex = GetPetActionInfo(3) return n == nil and tex == nil") + .unwrap()); + assert!(s.eval::("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::("return IsPetAttackActive(1)").unwrap()); + assert!(!s.eval::("return IsPetAttackActive(2)").unwrap()); + assert!(s + .eval::("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::("return PetHasActionBar()").unwrap(), 1); + assert!(s + .eval::("return GetPetActionsUsable() == nil") + .unwrap()); + } +} diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 70b38c9b..68af9a88 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -236,10 +236,12 @@ impl UiScript { // drop — all under one short borrow. A press fires OnClick when the hit frame registered // " + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:RegisterForDrag("LeftButton") + BenillaMacroButton_OnClick(self) + PickupMacro(getglobal("BenillaMacroFrame").macroBase + self:GetID()) + + + + + + + BenillaMacroPopupButton_OnClick(self) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:SetChecked(nil) + PickupMacro(getglobal("BenillaMacroFrame").selectedMacro) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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) + + + + + + + + + + + + + + + + + + + + + BenillaScrollBar_OnValueChanged(self, arg1) + + + + + + + + BenillaMacroFrameText_OnTextChanged(self) + self:ClearFocus() + + + + + BenillaMacroFrameScrollFrame_OnLoad(self) + + + + + + + + + + + + + + + + + + + + BenillaMacroFrame_OnLoad(self) + self:RegisterEvent("UPDATE_MACROS") + + BenillaMacroFrame_OnEvent(self, event) + + getglobal("BenillaMacroFrameTab2"):SetText(BenillaMacroFrame_TabTwoLabel()) + BenillaMacroFrame_OnShow() + UpdateMicroButtons() + + + BenillaMacroFrame_OnHide() + UpdateMicroButtons() + + BenillaScroll_OnMouseWheel(getglobal("BenillaMacroFrameScrollFrame"), arg1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaMacroPopupOkayButton_Update() + getglobal("BenillaMacroFrameSelectedMacroName"):SetText(self:GetText()) + + getglobal("BenillaMacroPopupFrame"):Hide() + + -- 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 + + + + + + + + + + + + BenillaFauxScrollFrame_OnLoad(self) + self.updateFunc = BenillaMacroPopupFrame_Update + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaMacroPopupFrame_OnShow(self) + BenillaMacroPopupFrame_OnHide(self) + BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaMacroPopupScrollFrame"), arg1) + + + diff --git a/crates/benilla/assets/ui/OptionsFrame.xml b/crates/benilla/assets/ui/OptionsFrame.xml index 6a5ee4b9..1132ab99 100644 --- a/crates/benilla/assets/ui/OptionsFrame.xml +++ b/crates/benilla/assets/ui/OptionsFrame.xml @@ -8,10 +8,25 @@ stages a pending value and commits through the era Apply button (0961), because a live write would rescale the very slider being dragged. - This window is the one deliberate era-1.15.9 skin in a 1.12 shell (0950): the 1.12 - OptionsFrame family (four separate panels) collapses into the era SettingsPanel look — - one window, a category list, one page area. Ground truth is the era dump - (wow-ui-source-era @ 1.15.9.68940), transcribed via the session's reference spec: + PROVENANCE (0985 — the three-source split, stated the same way in every era-shaped + window; GameMenuFrame.xml carries the sibling block): + · STRUCTURE & BEHAVIOR — the 1.15 era client (wow-ui-source-era @ 1.15.9.68940): + layout, geometry grammar, interaction, functionality — the single all-in-one + window, the category tree, the search, the Apply flow. If 1.15 has the mechanism, + it STAYS, even where the older clients had none — except a DIRECTED CUT the + director makes on look, each recorded (0989: the slider steppers and the corner + close X; the split bends to taste only by record, never silently). + · CHROME MECHANISM — the 1.13/1.14 clients' own UI source (wow-ui-source @ 1.14.4, + their System window family; 0981/0984): HOW surfaces are textured — the dialog + backdrop, the outline boxes, the title plate, the select/hover washes and their + tints, the hairlines. Geometry may bend where 1.15 furniture needs a seat (the + -52 band below); the mechanism holds. + · ART BYTES — the 1.12 MPQ chain only, or a plain color fill (0978 retired every + era-atlas extraction and the `era:` scheme; one install, nothing later-client at + runtime). + So: the 1.12 OptionsFrame family (four separate panels) collapses into the era's + single window — a category list, one page area — every texture 1.12-native. The era + art names in the bullets below stay as GEOMETRY CITATIONS, not drawn art: · **Root** — Blizzard_SettingsPanel.xml `SettingsPanel`: 920x724, CENTER, strata HIGH, hidden. Skin per `SettingsFrameTemplate` (Classic/Blizzard_SettingsPanelTemplates.xml): FlatPanelBackgroundTemplate at TOPLEFT (7,-18) → BOTTOMRIGHT (-3,3), the @@ -35,42 +50,77 @@ left hung 5 outside, middle stretched, all centered on the 22 box), 10x10 magnifier at LEFT (1,-1), 17x17 clear button at RIGHT (-3), TextInsets 16/20, instructions label GameFontDisableSmall tinted 0.35 at TOPLEFT (16,0) → BOTTOMRIGHT (-20,0). - Atlas members resolve by name through WoW-era/_extracted_ui/manifest.json (the 0950 - seam); era-only FILE textures ride the same extraction as `era:textures/.blp`. + THE 1.12 ART MAPPING (0978 base set, 0981 chrome — every file verified present in the + 5875 MPQ chain; the CHROME recipe is the 1.14 System window's own, read verbatim from + wow-ui-source @1.14.4 OptionsFrameTemplates.xml + InterfaceOptionsFrame.xml, whose + every piece is 1.12-native or a color): + · window chrome — BACKDROP_DIALOG_32_32, the 1.14 options frames' backdrop: tiled + UI-DialogBox-Background ground (uniform black at 60% alpha — the world reads + through, the reference screenshot's actual mechanism; 0978 misread that ground as + parchment) + UI-DialogBox-Border rope edge 32, insets 11/12/12/11. Title plate + UI-DialogBox-Header at the options frames' 300x68 TOP (0,+12) seat, title on it. + · the era's one dark Options_InnerFrame panel → TWO 1.14 OptionsFrameListTemplate + boxes: tooltip-border backdrops with the FILL AT ALPHA 0 (outlines — the ground + shows through) and the border tinted LIGHTGRAY_FONT_COLOR 0.6, tops at -52 (1.14's + own -40 band deepened 12 to hold the era search seat — 0984), tabs seated on the + category box's top edge (BOTTOMLEFT → box TOPLEFT (6,-3), the file-folder tuck). + · category rows — the 1.14 list-button wash, verbatim mechanism (0984, + OptionsFrameTemplates.{xml l.216-229, lua l.107-131}): ONE UI-QuestLogTitleHighlight + quad in ADD blend at the era plate seat (187x21 centered), steel-blue (.196,.388,.8) + as the hover and locked GOLD (1,1,0) as the selection, white hover/selected text. + The Options_CategoryHeader_ washes have no 1.12 counterpart and are DROPPED — + headers are their label alone. + · checkboxes: the UI-CheckBox four-state set at the era 30x29 seat (no HighlightTexture + — hover feedback stays the era row-wide wash, unchanged behavior). + · sliders: the 1.12 OptionsSliderTemplate groove — UI-SliderBar-Background/-Border + backdrop, its exact edge 8 / tile 8 / insets 3/3/6/6 — on a 17-tall Groove frame at + the era track seat, and the 32x32 UI-SliderBar-Button-Horizontal thumb. NO steppers + (0989, a directed cut of era structure): the bar is the whole control, 1.12's own + look — and the whole bar is live (thumb drag, or press anywhere on the track to + seat the thumb there and keep dragging; the engine's 0989 track-press law). + · tabs: UI-Character-ActiveTab / -InActiveTab flipped vertically via TexCoords (1.12 + ships only bottom-tab plates; the flip turns them top-tab), one stretched piece per + plate instead of the era three-slice, at the era plate heights (26 active / 23 not). + (1.14's own UI-OptionsFrame-{Active,InActive}Tab three-slice is a 2.x file — not in + the 1.12 chain, verified absent.) + · search box: the classic Common-Input-Border three-slice (the file IS in 1.12 + interface.MPQ — 0950's pull list mislabeled it era-only), the InputBoxTemplate UVs. + The era magnifier has no 1.12 counterpart and is DROPPED (the "Search" placeholder + carries the affordance) — and with it the era's 16-left TextInsets seat, which + exists to clear that magnifier: text and caret sit at 5, snug to the border ink + (0989). The clear-X wears the UI-Panel-MinimizeButton ball at its 17x17 seat, + half-lit, full-lit on hover (the era ClearButtonMixin's alpha law). + · divider: a 1-unit white hairline at 0.3 alpha, a color fill — what the era's + options_horizontaldivider atlas member actually is (decoded: a 630x1 pure-white row, + alpha peaking ~0.4, mean ~0.26). 0978's stretched UI-DialogBox-Divider is retired. SCOPE / divergences (noted, not bugs): - - **horizTile/vertTile are unsupported**, so every repeating tile is STRETCHED for now: - the `_UI-Frame-TitleTile` top band (a 256x28 repeat), the title-bg band under it, the - `!UI-Frame-LeftTile`/`!UI-Frame-RightTile` columns and the `_UI-Frame-Bot` strip. At - this window's spans the stretch reads as a soft smear of the tile — the gap to close - when tiling lands. - - **FlatPanelBackgroundTemplate is ONE solid fill here.** The era template is four - textures (two rounded-corner atlas pieces + two fills) all tinted the engine-side - global PANEL_BACKGROUND_COLOR = (0.122, 0.118, 0.129, 0.8) — the constant is defined in - the client binary, not the Lua dump; value read off the GlobalColor db2 mirror - (warcraft.wiki.gg/wiki/ColorMixin). One flat quad loses only the 16px rounded bottom - corners. The "lighter inner panel" on top of it is not a second fill — it is the real - `Options_InnerFrame` atlas member, placed below. - - **Close X wears the 1.12 UI-Panel-MinimizeButton art at the era seat** — which IS the - era's own close button: 1.15's UIPanelCloseButtonNoScripts names the same four - Interface\Buttons\UI-Panel-MinimizeButton-* files (Classic SharedUIPanelTemplates.xml - l.172-180), TOPRIGHT (+4,+4), 32x32. No divergence here at all. + - **No corner close X (0989, a directed cut)** — the era HAS one + (UIPanelCloseButtonNoScripts: the UI-Panel-MinimizeButton family at TOPRIGHT (+4,+4), + Classic SharedUIPanelTemplates.xml l.172-180), and 0981/0984 wore it; the director + cut it. The red Close button and ESC close the window, and the freed corner returns + the search box to the era's full-width TOPRIGHT seat. - **Tabs are STATIC MinimalTab plates** (slice B): Game wears the selected state permanently, AddOns the unselected gold — the era swaps these in code (MinimalTabMixin), and with nothing behind AddOns the selection can never change, so the two states are authored rather than driven. The mixin port comes with the addon list. Width is era's text+40 law via an OnUpdate measure poll; the era's measure-at-the-medium-font quirk is deliberately dropped (~6 units of padding). - - **Search filters nothing yet** — the box takes text, shows/hides its "Search" - placeholder and clear-X; wiring it to the category/row filter comes with the pages - slice. + - **Search WORKS (0984)** — the era SettingsPanel mechanism transcribed (the Script + block's search section): label-tag substring match scored by matched length, + results grouped under clickable category heads ordered by best score, a matched + child pulls its parent row in, "Search Results" title with Defaults hidden while + the search holds the page, the no-results line, and the search ends on a category + click or hide (the era's own ClearSearchBox law). The result rows are the LIVE + rows, reflowed — values, writes and dependencies all work mid-search. The two + strings ("Search Results", "No results found.") are ours: the era's + SETTINGS_SEARCH_* globals live in the client binary, not the dump. - **No scroll bars**: the static category tree (9 rows, 2 headers) fits its 199px column outright, and the deepest page (Audio, 7 rows) fits its area. MinimalScrollBar arrives when a page outgrows the space. - **Setting-page cuts (0957 Audio, 0959 Graphics)**: setting-row tooltips (the era OPTION_TOOLTIP_* hovers) are not wired; Defaults resets the VISIBLE page only (the - era confirm dialog is not modeled); track clicks do nothing (thumb drag + steppers - only — the engine slider's scrollbar-faithful posture); era rows with no 1.12 CVar + + era confirm dialog is not modeled); era rows with no 1.12 CVar + benilla knob behind them (output device, Dialog volume, loop music, channels, cache; the whole era Graphics dropdown zoo) don't ship. A slider shows the 1.12 panel's range even where the benilla knob's clamp is wider (uiScale's env clamp is 0.5..1.5): @@ -161,30 +211,51 @@ end end - -- Each row's OnLoad: remember the key + title, paint the label. The whole row identity - -- lives here so adding a category is one XML row + one key in the table above. + -- Each row's OnLoad: remember the key + title, paint the label, size the wash plate — + -- the era sized it via SetAtlas(name, true) at atlas size (187x21, centered: 6 units + -- in from either edge of the 199 row); with plain SetTexture the size is authored + -- here once — and set the 1.14 base tint: OptionsListButton_OnLoad's own + -- SetVertexColor(.196, .388, .8), the steel-blue every unselected row's hover wears + -- (OptionsFrameTemplates.lua l.130-131). The whole row identity lives here so adding + -- a category is one XML row + one key in the table above. function OptionsCategoryRow_OnLoad(row, key, label) row.categoryKey = key row.pageTitle = label row:SetText(label) + local bg = getglobal(row:GetName() .. "Bg") + bg:SetWidth(187) + bg:SetHeight(21) + bg:SetVertexColor(0.196, 0.388, 0.8) end - -- The selection: exactly one row wears options_list_active + a white label; everyone - -- else drops back to bare + gold (the era CategoryList mixin's SetCategoryActive, cut to - -- our static list). Also writes the page title and swaps the page BODY: a category with - -- rows (OPTIONS_PAGE_ROWS) shows its body refreshed from the CVars and Defaults goes - -- live with it; every other page is still empty, Defaults disabled. + -- The selection — 1.14's OptionsList_SelectButton/_ClearSelection pair, verbatim + -- (OptionsFrameTemplates.lua l.107-121): the selected row's wash goes GOLD (1,1,0) + -- and locks on; every other row drops back to the blue hover tint, wash off. The + -- white selected label is LockHighlight's own text state, carried by the font swap. + -- Also the era law on top (SettingsPanelMixin:SelectCategory clears the search box — + -- Blizzard_SettingsPanel.lua l.793): picking a category ENDS a live search; the + -- SetText("") round-trips through OnTextChanged -> restore -> back here, so this call + -- returns early and lets the re-entry do the work once. + -- Then the page swap: a category with rows (OPTIONS_PAGE_ROWS) shows its body + -- refreshed from the CVars and Defaults goes live with it; every other page is still + -- empty, Defaults disabled. function OptionsFrame_SelectCategory(key) OptionsFrame.selectedCategory = key + if OptionsFrameSearchBox:GetText() ~= "" then + OptionsFrameSearchBox:SetText("") + return -- the OnTextChanged restore re-entered with this same key and finished + end for _, k in ipairs(OPTIONS_CATEGORY_KEYS) do local row = getglobal("OptionsFrameCategoryListRow" .. k) local bg = getglobal("OptionsFrameCategoryListRow" .. k .. "Bg") if k == key then - bg:SetAtlas("Options_List_Active", true) + bg:SetVertexColor(1, 1, 0) + bg:Show() row:SetTextFontObject("GameFontHighlight") OptionsFrameContainerTitle:SetText(row.pageTitle) else - bg:SetTexture(nil) + bg:SetVertexColor(0.196, 0.388, 0.8) + bg:Hide() row:SetTextFontObject("GameFontNormal") end end @@ -200,6 +271,185 @@ end end + -- ── Search (0984): the era SettingsPanel mechanism, transcribed ───────────────────── + -- Blizzard_SettingsPanel.lua l.566-692 + Blizzard_Settings.lua l.65-77, cut to our + -- static rows: a setting's search tag is its LABEL uppercased (the era's default tag + -- is exactly the setting name — SettingControls.lua l.773); a query matches by plain + -- substring, scored by matched-word length; matches group under their category, the + -- groups ordered by their best score; each group opens with a clickable category + -- header (SettingsListSearchCategoryTemplate — click = jump to the category, which + -- ends the search); no match shows the section-header "no results" line. The results + -- ARE the live rows — the era reflows the real initializers into a fresh vertical + -- layout, so ours re-anchor the real row frames (values, writes, dependencies all + -- work in place) and the restore path lays every page back down by the one layout + -- law below. + + -- The era's word list (l.569-572): the WHOLE query first — a phrase match outranks + -- its own words — then every comma/space-separated token. + function OptionsSearch_Words(text) + local up = string.upper(text) + local words = { up } + for w in string.gmatch(up, "([^, ]+)") do + table.insert(words, w) + end + return words + end + + -- The era score (Blizzard_Settings.lua l.65-77 MatchesSearchTags): first word that + -- hits the tag scores last-first (matched length - 1); no hit is no match. Plain + -- find — the era passes plain=true, no pattern magic. + function OptionsSearch_Score(tag, words) + for _, w in ipairs(words) do + local first, last = string.find(tag, w, 1, true) + if first then return last - first end + end + return nil + end + + -- The era gather+group (l.575-636): rows bucketed by score, scores walked high to + -- low; a category's group is born the first time one of its rows surfaces — so the + -- group ORDER is best-score order — and collects that category's matches as the walk + -- descends. Returns the ordered group list { key, rkeys } .. . + function OptionsSearch_FindMatches(text) + local words = OptionsSearch_Words(text) + local buckets, scores = {}, {} + for _, key in ipairs(OPTIONS_CATEGORY_KEYS) do + if OPTIONS_PAGE_ROWS[key] then + for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do + local row = getglobal("OptionsFrameContainerBody" .. key .. rkey) + local s = OptionsSearch_Score(row.searchTag, words) + if s then + if not buckets[s] then + buckets[s] = {} + table.insert(scores, s) + end + table.insert(buckets[s], { key = key, rkey = rkey }) + end + end + end + end + table.sort(scores, function(a, b) return a > b end) + local groups, order = {}, {} + for _, s in ipairs(scores) do + for _, m in ipairs(buckets[s]) do + if not groups[m.key] then + groups[m.key] = {} + table.insert(order, m.key) + end + table.insert(groups[m.key], m.rkey) + end + end + return order, groups + end + + -- THE one layout law (era SettingsList: verticalPad 10, padLeft 25/padRight 0 → + -- first element at (10,-12) off the body, spacing 9 → chain at (0,-9)). The rows' + -- authored XML anchors state exactly this chain as the initial layout; search + -- reflows steal rows out of it, and this function is how ANY element — row or search + -- head — takes a seat in it. prev is a global frame NAME (nil = the chain head). + function OptionsSearch_Place(name, prev) + local f = getglobal(name) + f:ClearAllPoints() + if prev then + f:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, -9) + f:SetPoint("TOPRIGHT", prev, "BOTTOMRIGHT", 0, -9) + else + f:SetPoint("TOPLEFT", "OptionsFrameContainerBody", "TOPLEFT", 10, -12) + f:SetPoint("TOPRIGHT", "OptionsFrameContainerBody", "TOPRIGHT", -20, -12) + end + end + + -- Lay a page's rows back down on their authored chain (the XML anchors' law, + -- re-stated after a search reflow; the pin test holds the two equal). + function OptionsPage_Layout(key) + local prev = nil + for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do + local name = "OptionsFrameContainerBody" .. key .. rkey + OptionsSearch_Place(name, prev) + getglobal(name):Show() + prev = name + end + end + + -- The results view (era OnSearchTextChanged's hasText arm, l.659-691): every page + -- body SHOWS (they are bare row hosts sharing one rect) with every row hidden, then + -- the groups chain down — category head, then its matches; a matched CHILD row pulls + -- its parent row in first (l.665-670, the era parentInitializer rule — our Audio + -- volumes ride under Master). Title reads "Search Results", Defaults hides while the + -- search holds the page (l.684-688), and an empty result shows the no-results line + -- (l.680-682). + function OptionsSearch_Display(text) + for k in pairs(OPTIONS_PAGE_ROWS) do + getglobal("OptionsFrameContainerBody" .. k):Show() + for _, rkey in ipairs(OPTIONS_PAGE_ROWS[k]) do + getglobal("OptionsFrameContainerBody" .. k .. rkey):Hide() + end + getglobal("OptionsFrameContainerBodySearchHead" .. k):Hide() + end + OptionsFrameContainerTitle:SetText("Search Results") + OptionsFrameContainerDefaults:Hide() + + local order, groups = OptionsSearch_FindMatches(text) + if table.getn(order) == 0 then + OptionsFrameContainerBodyNoResults:Show() + return + end + OptionsFrameContainerBodyNoResults:Hide() + local prev = nil + for _, key in ipairs(order) do + local head = "OptionsFrameContainerBodySearchHead" .. key + OptionsSearch_Place(head, prev) + getglobal(head):Show() + prev = head + local placed = {} + for _, rkey in ipairs(groups[key]) do + local row = getglobal("OptionsFrameContainerBody" .. key .. rkey) + if row.parentRow and not placed[row.parentRow] then + placed[row.parentRow] = 1 + local pname = "OptionsFrameContainerBody" .. key .. row.parentRow + OptionsSearch_Place(pname, prev) + getglobal(pname):Show() + prev = pname + end + if not placed[rkey] then + placed[rkey] = 1 + local name = "OptionsFrameContainerBody" .. key .. rkey + OptionsSearch_Place(name, prev) + row:Show() + prev = name + end + end + OptionsPage_Refresh(key) + end + end + + -- Ending a search: every page back on its authored chain, heads away, and the + -- selection re-applied — SelectCategory re-shows the one body, retitles, and brings + -- Defaults back (the era's no-text arm, l.655-658). + function OptionsSearch_Restore() + for k in pairs(OPTIONS_PAGE_ROWS) do + OptionsPage_Layout(k) + getglobal("OptionsFrameContainerBodySearchHead" .. k):Hide() + end + OptionsFrameContainerBodyNoResults:Hide() + OptionsFrameContainerDefaults:Show() + OptionsFrame_SelectCategory(OptionsFrame.selectedCategory or "Controls") + end + + -- The text gate (era OnSearchTextChanged l.640-652): a repeat of the same text is a + -- no-op, and the FIRST change (searchText still nil — load-time initialization) + -- arms without acting; text clearing restores only if a search was actually live. + function OptionsSearch_OnTextChanged(text) + if text == OptionsFrame.searchText then return end + local had = OptionsFrame.searchText and OptionsFrame.searchText ~= "" + OptionsFrame.searchText = text + if text == "" then + if had then OptionsSearch_Restore() end + else + OptionsSearch_Display(text) + end + end + -- ── The setting pages: 1.12 rows in the era Settings grammar ──────────────────────── -- One machinery, every page (Audio 0957, the first). Semantics (rows, CVars, labels, -- dependencies, the click-sound quirk) are the 1.12 client's own panels, quoted @@ -220,6 +470,10 @@ function OptionsRow_OnLoad(row, cvar, label, child) row.cvar = cvar + -- The search tag: the era's default is exactly the setting's display name + -- (SettingControls.lua l.773 AddSearchTags(name)), uppercased at registration + -- (Blizzard_Settings.lua l.60). + row.searchTag = string.upper(label) local l = getglobal(row:GetName() .. "Label") l:SetText(label) if child then @@ -335,13 +589,6 @@ end end - function OptionsStepper_OnClick(btn, dir) - local slider = getglobal(btn:GetParent():GetName() .. "Slider") - -- era steppers: one step per click, the checkbox-on kit (MinimalSlider.lua l.53-57). - PlaySound("igMainMenuOptionCheckBoxOn") - slider:SetValue(slider:GetValue() + dir * slider:GetValueStep()) - end - function OptionsPage_Refresh(key) local base = "OptionsFrameContainerBody" .. key for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do @@ -483,16 +730,21 @@ 175 width is never rendered in the era either — the ScrollBox stretches every row to the scroll target's width, 199 with no scroll bar (CategoryList.lua l.176-197 + ScrollBoxViewUtil), and ours is the no-bar case. Gold label centered in the row (the - full-row box below); a - CENTER bg that is EMPTY until the hover/selected atlas lands on it (options_list_hover - / options_list_active at atlas size — 187x21, centered: 6 units in from either edge of - the 199 row, the alignment the first cut's 175-wide rows broke). Selection is - OptionsFrame_SelectCategory's; hover only ever paints a row that is NOT the selection. --> + full-row box below). + The select/hover wash is the 1.14 OptionsListButtonTemplate mechanism VERBATIM (0984; + wow-ui-source @1.14.4 OptionsFrameTemplates.xml l.216-229 + .lua l.107-131 — 0978's + two-texture BLEND washes drew as opaque bars, the director's report): ONE + UI-QuestLogTitleHighlight quad in ADD blend, tinted steel-blue (.196,.388,.8) as the + hover and locked GOLD (1,1,0) on the selected row — 1.14's SetVertexColor pair around + LockHighlight/UnlockHighlight, carried here as Show/Hide on the Bg (our selection is + Lua-driven, not button-state-driven). Geometry stays the era plate seat (187x21 + centered, 6 units in from either edge of the 199 row — sized once in + OptionsCategoryRow_OnLoad); hover text goes white via HighlightFont, 1.14's own trio. --> - + - - - - - @@ -551,15 +797,47 @@ + + + + 30x29 box at LEFT = row CENTER-80 (l.473). The checkbox art is the 1.12 UI-CheckBox + set at the era state seats (0978 mapping; the era used its minimal set): Up normal, + Down pushed, Check checked, Check-Disabled disabled+checked; no HighlightTexture — + hover feedback stays the era row-wide Hover wash (HoverBackgroundTemplate: white + a=0.1, hung 10 left / 5 right of the row), so the box doesn't double-light. --> @@ -585,10 +863,10 @@ - - - - + + + + OptionsCheckbox_OnClick(this) OptionsRow_Hover(this:GetParent(), 1) @@ -605,13 +883,20 @@ + (its 40 height overrides the row symmetrically). Inside: the Slider inset 19 each + side — spanning the groove exactly — hit area the full height, the way era's is + (mouse-enabled by the widget's own construction, 0250's law — no attr needed). The + TRACK is the 0978 mapping — the 1.12 OptionsSliderTemplate groove verbatim + (extracted OptionsFrameTemplates.xml l.74: UI-SliderBar-Background/-Border backdrop, + edge 8, tile 8, insets 3/3/6/6) on a 17-tall Groove frame at the era track seat (the + era's own three-slice was 17 tall, centered), with the 1.12 32x32 + UI-SliderBar-Button-Horizontal thumb. NO steppers — 0984 wore them at 26x26 as kept + era structure; 0989 cut them by the director's call (a recorded exception to 0985's + keep): the bar alone is the control, and the whole bar is live — thumb drag always + worked but the visible knob is a small target, so 0989's engine track-press law + seats the thumb under a press anywhere on the bar and drags on from there. The + readout is era's RIGHT label seat — 6 right of the control (MinimalSlider.xml + l.73-77), rounded-percent formatted. --> @@ -645,54 +930,35 @@ + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + OptionsSlider_OnValueChanged(this, arg1) OptionsRow_Hover(this:GetParent():GetParent(), 1) OptionsRow_Hover(this:GetParent():GetParent(), nil) - - @@ -705,123 +971,116 @@ + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - + + + - + + + + + + + + + + + + + + this:SetBackdropColor(0, 0, 0, 0) + this:SetBackdropBorderColor(0.6, 0.6, 0.6) + + + + + + + + + + + + + + + + this:SetBackdropColor(0, 0, 0, 0) + this:SetBackdropBorderColor(0.6, 0.6, 0.6) + + + + + + - + + - + + - + + - + + @@ -1170,18 +1489,12 @@ tinted 0.35). OnTextChanged below toggles it on first text. --> - + - - - - - - + if this:GetText() == "" then OptionsFrameSearchBoxInstructions:Show() @@ -1217,28 +1531,15 @@ OptionsFrameSearchBoxInstructions:Hide() OptionsFrameSearchBoxClearButton:Show() end + OptionsSearch_OnTextChanged(this:GetText()) this:ClearFocus() - - - +