- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference - the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil - the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them - the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
416 lines
18 KiB
Rust
416 lines
18 KiB
Rust
//! The **pet spellbook** — the spellbook window's second tab (decision 1032).
|
|
//!
|
|
//! Its source has been on the wire and parsed since 0988 and nothing read it:
|
|
//! [`benilla_protocol::messages::PetSpells::spells`], the packed-word list `SMSG_PET_SPELLS`
|
|
//! carries after the ten bar slots. This module is the join that turns it into a book.
|
|
//!
|
|
//! **It is not the player book with a flag on it**, and the three differences are the reason this
|
|
//! is its own module rather than an arm of [`crate::ui_spellbook`]:
|
|
//!
|
|
//! - **A different add-gate.** `0x4b2f90` keeps a spell out only on `Attributes & 0x80`
|
|
//! (`DO_NOT_DISPLAY`) — `SpellDisplay::in_pet_book`. The player book's `IS_TRADESKILL` and
|
|
//! `castUI` legs are simply absent.
|
|
//! - **No tabs.** `GetNumSpellTabs`/`GetSpellTabInfo` never see this book;
|
|
//! `SpellBook_GetSpellID`'s pet arm is a bare `return id`, so a button's own 1..12 id *is* the
|
|
//! book id.
|
|
//! - **Two extra per-slot answers the player book has no analogue for** — the autocast pair, read
|
|
//! off the pet's **raw** words (bits 31/30) rather than the filtered book, and the packed word
|
|
//! itself, which is what `PickupSpell(id, "pet")` puts on the cursor.
|
|
//!
|
|
//! The **order** is shared, though: `0x4b2fd0(ecx = 0, edx = 1)` sorts the pet array with
|
|
//! `0x4b30c0`, the player book's own comparator (name, then the parsed rank number), so
|
|
//! [`crate::ui_spellbook::spell_sort_key`] serves both.
|
|
//!
|
|
//! ## Where each answer comes from
|
|
//!
|
|
//! | Lua | source |
|
|
//! |---|---|
|
|
//! | `GetSpellName` / `GetSpellTexture` / `IsSpellPassive` | `Spell.dbc` via [`Spells`] |
|
|
//! | `GetSpellCooldown(id, "pet")` | **bank 1** — [`PetBar::cooldowns`], the pet's own store. The reference reaches it with the same `0x6e2ea0(edx = isPet)` `GetPetActionCooldown` uses (`0x4b40dd`), so a spell on the bar and the same spell in the book read one timer, and they do here too. |
|
|
//! | `GetSpellAutocast` | the raw word's bits 31/30 |
|
|
//! | `IsCurrentCast` | the pet's own auras — `0x4b36f0`'s pet arm scans `[pet.fields + 0xa4]`, which is exactly the predicate the bar's [`crate::ui_pet`] already applies per slot |
|
|
//! | `HasPetSpells`'s token | `ChrClasses.dbc` field 4, via [`crate::chr_classes`] |
|
|
//!
|
|
//! ## The two verbs
|
|
//!
|
|
//! Both are pet verbs, and neither is the player book's:
|
|
//!
|
|
//! - **`CastSpell(id, "pet")` → `CMSG_PET_ACTION`** with a **synthesized** type-1 word,
|
|
//! `0x0100_0000 | (spellId & 0xFFFF)` (`0x4b350a`-`0x4b3516`), aimed at the passed target or,
|
|
//! absent one, the current selection (`0x4b34af`). So the book can cast a pet spell that is not
|
|
//! on the bar at all — the word is built for the send, never looked up.
|
|
//! - **`ToggleSpellAutocast(id, "pet")` → `CMSG_PET_SPELL_AUTOCAST 0x2F3`**, naming the **spell**.
|
|
//! Its sender `0x4bccb0` does three things before the packet leaves, and all three are
|
|
//! reproduced in [`drain_pet_book`]: it flips bit 30 in the raw word in place, it **mirrors that
|
|
//! bit onto every bar slot carrying the same action** (`0x4bcd20`-`0x4bcd5a`, compared under
|
|
//! `& 0x3FFFFFFF`), and it fires `PET_BAR_UPDATE`. That mirror is why toggling autocast in the
|
|
//! book lights the sparkle on the bar button in the same frame.
|
|
|
|
use std::time::Instant;
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_formats::SpellDisplay;
|
|
use benilla_protocol::messages::PetActionEntry;
|
|
use benilla_ui::script::{PetBookState, SpellSlotView, UiScript};
|
|
|
|
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
|
use crate::target::Selection;
|
|
use crate::ui_action::Spells;
|
|
use crate::ui_pet::PetBar;
|
|
use crate::ui_script::UiInput;
|
|
use crate::ui_unit::UnitFeed;
|
|
|
|
pub(crate) struct UiPetBookPlugin;
|
|
|
|
impl Plugin for UiPetBookPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_systems(
|
|
Update,
|
|
(
|
|
// Rides the unit feed beside the player book's, and before the input pass so a
|
|
// tab flipped this frame already reads a populated book.
|
|
feed_pet_book
|
|
.in_set(UnitFeed)
|
|
.before(crate::ui_action::CooldownEvents)
|
|
.before(UiInput),
|
|
// After the input pass, and — like `ui_pet`'s own drain — writing back into
|
|
// `PetBar`, whose next feed carries the mirrored autocast bit.
|
|
drain_pet_book.after(UiInput),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// What the feed last pushed, so `SPELLS_CHANGED` fires on a real change rather than every frame
|
|
/// (the [`crate::ui_spellbook`] memory pattern).
|
|
#[derive(Default)]
|
|
struct FeedMemory {
|
|
pushed: PetBookState,
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
|
|
fn feed_pet_book(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
bar: Res<PetBar>,
|
|
spells: Option<Res<Spells>>,
|
|
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
|
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
|
index: Res<GuidIndex>,
|
|
stores: Query<&ObjectStore>,
|
|
clock: Res<crate::ui_script::UiClock>,
|
|
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let memory = memory.get(&script);
|
|
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands. Unlike the
|
|
// bar (which can still draw its command tokens), a book with no catalog has no slots at all.
|
|
let Some(spells) = spells.as_deref() else {
|
|
return;
|
|
};
|
|
let now = Instant::now();
|
|
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
|
|
let pet_store = index
|
|
.0
|
|
.get(&bar.spells.pet_guid)
|
|
.and_then(|&e| stores.get(e).ok());
|
|
|
|
let mut fresh = PetBookState {
|
|
// The token is the PLAYER's class, not the pet's — `0x4b4463` reads `player.fields + 0x79`.
|
|
// A warlock reading "Demon" while a hunter reads "Pet" is the whole of it.
|
|
token: self_q.single().ok().and_then(|s| {
|
|
let class = u32::from(s.0.unit_class()?);
|
|
classes
|
|
.as_deref()
|
|
.map(|t| t.0.pet_name_token(class).to_string())
|
|
}),
|
|
slots: Vec::new(),
|
|
};
|
|
if bar.has_bar() {
|
|
for &entry in &bar.spells.spells {
|
|
let spell_id = entry.action();
|
|
let Some(d) = spells.catalog.get(spell_id) else {
|
|
// `0x4b2f90`'s first gate: no `Spell.dbc` record, no book slot.
|
|
continue;
|
|
};
|
|
if !d.in_pet_book() {
|
|
continue;
|
|
}
|
|
fresh
|
|
.slots
|
|
.push(slot_view(entry, d, &bar, pet_store, now, anchor, ui_now));
|
|
}
|
|
// `0x4b2fd0`'s re-sort, with the player book's own comparator.
|
|
fresh
|
|
.slots
|
|
.sort_by_key(|s| crate::ui_spellbook::spell_sort_key_of(&s.name, s.rank.as_deref()));
|
|
}
|
|
|
|
if fresh != memory.pushed {
|
|
debug!("ui_pet_book: fed {} pet spell(s)", fresh.slots.len());
|
|
let changed = book_changed(&fresh, &memory.pushed);
|
|
script.set_pet_book(fresh.clone());
|
|
memory.pushed = fresh;
|
|
// The reference fires ONE `SPELLS_CHANGED` for both books off the shared re-sort
|
|
// (`0x4b2fd0` tail-jumps `SignalEvent(0x104)`), which is also what re-reads the spell
|
|
// buttons. The cooldown/autocast half rides `PET_BAR_UPDATE` instead — `SpellButton_OnLoad`
|
|
// registers it precisely so the pet page can repaint without a book change
|
|
// (`SpellBookFrame.lua:214`, `l.227-231`) — and `ui_pet`'s own feed already fires that.
|
|
if changed {
|
|
script.fire_event("SPELLS_CHANGED", vec![]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Did the book itself move, as opposed to a per-slot cooldown/autocast/ring bit? Only the former
|
|
/// is `SPELLS_CHANGED`; the latter reaches the buttons through `PET_BAR_UPDATE`.
|
|
fn book_changed(fresh: &PetBookState, old: &PetBookState) -> bool {
|
|
fresh.token != old.token
|
|
|| fresh.slots.len() != old.slots.len()
|
|
|| fresh.slots.iter().zip(&old.slots).any(|(a, b)| {
|
|
(a.spell_id, &a.name, &a.rank, &a.texture, a.passive)
|
|
!= (b.spell_id, &b.name, &b.rank, &b.texture, b.passive)
|
|
})
|
|
}
|
|
|
|
/// One book slot, fully resolved.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn slot_view(
|
|
entry: PetActionEntry,
|
|
d: &SpellDisplay,
|
|
bar: &PetBar,
|
|
pet_store: Option<&ObjectStore>,
|
|
now: Instant,
|
|
anchor: Instant,
|
|
ui_now: f64,
|
|
) -> SpellSlotView {
|
|
let spell_id = entry.action();
|
|
SpellSlotView {
|
|
spell_id,
|
|
name: d.name.clone(),
|
|
rank: d.rank.clone(),
|
|
texture: d.icon.clone(),
|
|
passive: d.passive,
|
|
// `IsCurrentCast`'s pet arm (`0x4b36f0`, and the delegate `0x4b3600` it shares a shape
|
|
// with): the ring lights while the spell's aura is on the PET. Same predicate the bar's
|
|
// ActiveIcon swap uses, so a lit book slot and a lit bar slot always agree.
|
|
current: pet_store
|
|
.is_some_and(|s| crate::ui_action::toggle::active_action_toggle(spell_id, d, s)),
|
|
// **Bank 1** — the pet's own store, the same one `GetPetActionCooldown` reads.
|
|
cooldown: bar
|
|
.cooldowns
|
|
.info(spell_id, 0, Some(d), now)
|
|
.ui_triple(anchor, ui_now),
|
|
// The autocast pair off the RAW word. `autocast_allowed` is additionally gated on the
|
|
// spell resolving in `Spell.dbc` (`0x4bdd65`'s own second test) — which it has, or we
|
|
// would not be here.
|
|
autocast: Some((entry.autocast_allowed(), entry.autocast_on())),
|
|
packed: entry.packed,
|
|
}
|
|
}
|
|
|
|
/// Drain the pet book's two intents.
|
|
fn drain_pet_book(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut bar: ResMut<PetBar>,
|
|
selection: Res<Selection>,
|
|
commands: Res<NetCommands>,
|
|
spells: Option<Res<Spells>>,
|
|
pet: crate::ui_pet::PetUnit,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let casts = script.take_pet_spell_casts();
|
|
let autocasts = script.take_pet_spell_autocasts();
|
|
if casts.is_empty() && autocasts.is_empty() {
|
|
return;
|
|
}
|
|
let pet_guid = bar.spells.pet_guid;
|
|
if pet_guid == 0 {
|
|
debug!("ui_pet_book: dropping queued pet book intents — the bar is gone");
|
|
return;
|
|
}
|
|
let target_guid = selection.guid.unwrap_or(0);
|
|
let pet_store = pet.store(pet_guid);
|
|
|
|
for spell_id in casts {
|
|
// **The press-again-to-cancel arm comes first** (`0x4b33af`-`0x4b3461`), exactly as it does
|
|
// on the bar: a click on a pet spell whose aura is already running on the pet sends
|
|
// `CMSG_PET_CANCEL_AURA` (0x26B) **and returns** — the ordinary order never leaves. Same
|
|
// predicate, same order of tests, one implementation
|
|
// (`crate::ui_action::toggle::active_action_toggle`).
|
|
let display = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
|
if let (Some(d), Some(store)) = (display, pet_store) {
|
|
if crate::ui_action::toggle::active_action_toggle(spell_id, d, store) {
|
|
debug!("ui_pet_book: cast {spell_id} cancels the pet's own aura");
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
|
|
continue;
|
|
}
|
|
}
|
|
// The synthesized word, `0x4b350a`-`0x4b3516`: `(spellId & 0xFFFF) | 0x01000000`. Type 1
|
|
// is the client's spell branch and the autocast bits are left clear — this word describes
|
|
// the ORDER, not the slot, so nothing is read back out of it.
|
|
let packed = 0x0100_0000 | (spell_id & 0xFFFF);
|
|
debug!("ui_pet_book: cast {spell_id} (target {target_guid:#x})");
|
|
let _ = commands.0.send(ClientCommand::PetAction {
|
|
pet_guid,
|
|
packed,
|
|
target_guid,
|
|
});
|
|
}
|
|
|
|
for spell_id in autocasts {
|
|
let Some(on) = flip_autocast(&mut bar.spells, spell_id) else {
|
|
continue;
|
|
};
|
|
// `0x4bcdc6`'s `SignalEvent(0x161)` — the bar repainted before the packet left, which is
|
|
// the same "write locally, never wait for a reply" law the rest of this bar runs on
|
|
// (`ui_pet`'s module doc). Bumping the signal count is how that repaint reaches the feed.
|
|
bar.bar_signals = bar.bar_signals.wrapping_add(1);
|
|
debug!("ui_pet_book: autocast {spell_id} -> {on}");
|
|
let _ = commands.0.send(ClientCommand::PetSpellAutocast {
|
|
pet_guid,
|
|
spell_id,
|
|
enabled: on,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// `0x4bccb0`'s local half, in its own order: find the spell's **raw** word, flip bit 30 in place,
|
|
/// and mirror the new bit onto every **bar** slot carrying the same action. Returns the new state,
|
|
/// or `None` when the reference would have aborted before sending anything.
|
|
///
|
|
/// The mirror is the load-bearing half and the easy one to miss (`0x4bcd20`-`0x4bcd5a`): the loop
|
|
/// walks all ten bar words, compares `(bar[i] ^ word) & 0x3FFFFFFF` — type and action, autocast
|
|
/// bits excluded — and copies bit 30 across on a match. Without it, flipping autocast in the book
|
|
/// leaves the bar button's sparkle stale until the next `SMSG_PET_SPELLS`.
|
|
///
|
|
/// The gate is `0x4bccf5`: **the word must be autocast-ALLOWED** (bit 31). A passive's is not, and
|
|
/// neither is a command token's — so neither can be toggled from anywhere.
|
|
fn flip_autocast(
|
|
spells: &mut benilla_protocol::messages::PetSpells,
|
|
spell_id: u32,
|
|
) -> Option<bool> {
|
|
let word = spells
|
|
.spells
|
|
.iter_mut()
|
|
.find(|w| w.action() == spell_id && w.autocast_allowed())?;
|
|
let on = !word.autocast_on();
|
|
*word = word.with_autocast(on);
|
|
let action = word.packed & 0x3FFF_FFFF;
|
|
for slot in &mut spells.bar {
|
|
if slot.packed & 0x3FFF_FFFF == action {
|
|
*slot = slot.with_autocast(on);
|
|
}
|
|
}
|
|
Some(on)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use benilla_protocol::messages::{
|
|
PetSpells, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE,
|
|
};
|
|
|
|
fn word(kind: u8, spell_id: u32) -> PetActionEntry {
|
|
PetActionEntry::from((u32::from(kind) << 24) | spell_id)
|
|
}
|
|
|
|
/// **The pet book's add-gate is not the player book's.** `0x4b2f90` tests exactly one bit —
|
|
/// `Attributes & 0x80` (DO_NOT_DISPLAY) — where the player book also refuses `IS_TRADESKILL`
|
|
/// (`0x20`) and `castUI != 0`. A spell with either of those two SHOWS in a pet book.
|
|
#[test]
|
|
fn the_pet_books_gate_is_do_not_display_alone() {
|
|
let shown = |attributes: u32, cast_ui: u32| SpellDisplay {
|
|
attributes,
|
|
cast_ui,
|
|
..Default::default()
|
|
};
|
|
assert!(shown(0, 0).in_pet_book());
|
|
assert!(!shown(0x80, 0).in_pet_book(), "DO_NOT_DISPLAY hides it");
|
|
// The two the PLAYER book additionally refuses — kept here, and this is the whole point.
|
|
assert!(
|
|
shown(0x20, 0).in_pet_book(),
|
|
"IS_TRADESKILL is not a pet-book gate"
|
|
);
|
|
assert!(!shown(0x20, 0).in_spellbook());
|
|
assert!(shown(0, 1).in_pet_book(), "castUI is not a pet-book gate");
|
|
assert!(!shown(0, 1).in_spellbook());
|
|
// A passive is shown in both — being passive was never a book gate.
|
|
assert!(shown(0x40, 0).in_pet_book() && shown(0x40, 0).in_spellbook());
|
|
}
|
|
|
|
/// The autocast toggle's local half (`0x4bccb0`): the flip lands on the raw **book** word AND
|
|
/// on every **bar** slot carrying the same action, compared under `& 0x3FFFFFFF`. Missing the
|
|
/// mirror leaves the bar button's sparkle stale until the next `SMSG_PET_SPELLS`.
|
|
#[test]
|
|
fn the_book_toggle_mirrors_onto_the_bar() {
|
|
const CLAW: u32 = 16827;
|
|
let mut spells = PetSpells {
|
|
pet_guid: 0x2A,
|
|
spells: vec![
|
|
word(PET_ACT_DISABLED, CLAW), // autocastable, currently OFF
|
|
word(PET_ACT_PASSIVE, 3025), // a passive: not autocastable
|
|
],
|
|
..Default::default()
|
|
};
|
|
// Claw sits in bar slot 4; slot 5 holds a DIFFERENT spell with the same low bits pattern
|
|
// is impossible, so use a plain second spell to prove the compare is by action.
|
|
spells.bar[4] = word(PET_ACT_DISABLED, CLAW);
|
|
spells.bar[5] = word(PET_ACT_DISABLED, 2649);
|
|
|
|
assert_eq!(flip_autocast(&mut spells, CLAW), Some(true));
|
|
assert!(spells.spells[0].autocast_on(), "the book word flipped");
|
|
assert!(spells.bar[4].autocast_on(), "…and so did the bar slot");
|
|
assert!(
|
|
!spells.bar[5].autocast_on(),
|
|
"a different action is untouched"
|
|
);
|
|
|
|
// Flipping back does the same in reverse.
|
|
assert_eq!(flip_autocast(&mut spells, CLAW), Some(false));
|
|
assert!(!spells.spells[0].autocast_on());
|
|
assert!(!spells.bar[4].autocast_on());
|
|
|
|
// `0x4bccf5`: a word that is not autocast-ALLOWED aborts before anything is written.
|
|
assert_eq!(
|
|
flip_autocast(&mut spells, 3025),
|
|
None,
|
|
"a passive cannot be toggled"
|
|
);
|
|
assert_eq!(
|
|
flip_autocast(&mut spells, 99),
|
|
None,
|
|
"nor can a spell the pet lacks"
|
|
);
|
|
}
|
|
|
|
/// A word already ON flips OFF and an `ACT_ENABLED` word starts ON — the two server states our
|
|
/// decode collapses onto one type (`0988`), which is exactly why the flip reads bit 30 and not
|
|
/// the type byte.
|
|
#[test]
|
|
fn the_flip_reads_bit_thirty_not_the_type_byte() {
|
|
const GROWL: u32 = 2649;
|
|
let mut spells = PetSpells {
|
|
spells: vec![word(PET_ACT_ENABLED, GROWL)],
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
spells.spells[0].autocast_on(),
|
|
"ACT_ENABLED means bit 30 is set"
|
|
);
|
|
assert_eq!(flip_autocast(&mut spells, GROWL), Some(false));
|
|
assert!(!spells.spells[0].autocast_on());
|
|
assert!(
|
|
spells.spells[0].autocast_allowed(),
|
|
"…and it stays ALLOWED — only bit 30 moves"
|
|
);
|
|
}
|
|
}
|