- the eight heaviest UI feeds — spellbook, party, character, bags, talents, units, pet, player — rebuild only when an input moved instead of every frame: 216 → 12 µs a frame parked - the character window's cost settles once it closes: enchant countdowns feed by the second instead of firing an inventory event into all 20 slots every frame, the paper-doll booth parks with its camera, and a window hidden under the cursor fires OnLeave so its tooltip goes with it — the reference's hover-hide law - the walk stops paying for finished work: the loading-screen tally's per-placement walk latches off once the world is ready, the settled placement spawner is one integer compare, and billboards stop dirtying every card's transform - never-rendering hierarchy nodes — rig anchors, joints, doodad hosts — keep the hide chain but leave the per-camera visibility sweep: 25,198 → 18,026 swept entities at Goldshire Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
619 lines
30 KiB
Rust
619 lines
30 KiB
Rust
//! The spellbook window feed (decision 0216 §8, slice 5) — the spell **source** for the cursor
|
||
//! payload system: builds a book model from `PlayerActions.spells` (`SMSG_INITIAL_SPELLS`, the
|
||
//! known-spell set `ui_action.rs` already streams) through the `SpellCatalog`/`SkillLineCatalog`
|
||
//! join, and drives `benilla_ui::script::spellbook`'s snapshot + cast-drain seam behind
|
||
//! `SpellBookFrame.xml` (the P key, `ui_script/input.rs`).
|
||
//!
|
||
//! The **add-gate** is byte-verified (decision 0227; wow-re `system/ui/scratch/
|
||
//! spellbook-book-build.md`): a known spell reaches the book only when
|
||
//! [`SpellDisplay::in_spellbook`](benilla_formats::SpellDisplay::in_spellbook) — `Attributes` not
|
||
//! `DO_NOT_DISPLAY`/`IS_TRADESKILL`, `castUI == 0`. Languages, armor/weapon proficiencies, and
|
||
//! hidden racial passives are excluded exactly as the real client excludes them, so a live
|
||
//! character's book no longer grows the junk tabs the pre-0227 build showed.
|
||
//!
|
||
//! Tab classification is byte-verified (decision 0228; wow-re
|
||
//! `system/ui/scratch/spellbook-book-build.md` §3): a spell's `SkillLineAbility` line is routed
|
||
//! through the per-race/class table (`0x6ddf90` → `SkillRaceClassInfo.dbc`) — if no row admits the
|
||
//! player's race+class, or the matching row carries `SKILL_FLAG_DISPLAY_SORTED` (`flags & 0x80`),
|
||
//! the spell lands in the **General** tab (key 0) instead of its line's own. That collapses
|
||
//! racials, generic (GenericDND), and cross-class spells (a warrior's cheated Fireball) into
|
||
//! General, while class-native abilities keep their class tab — exactly what the real client shows.
|
||
//! [`build_book`] reads the player's race/class from the self `ObjectStore` and defers the routing
|
||
//! to [`SkillLineCatalog::spell_tab`]; absent a character (capture with no self player) or the
|
||
//! `SkillRaceClassInfo` data, the collapse is skipped and each line keeps its own tab.
|
||
//!
|
||
//! Tab order + book order follow the same §5 (`0x4b3040`/`0x4b30c0`): **General/key-0 pinned
|
||
//! first**, then alphabetical by `SkillLine.dbc` name (locale collator `0x64a480` — plain byte
|
||
//! order stands in for enUS); within a tab, name → **parsed rank number** ([`spell_sort_key`]).
|
||
//!
|
||
//! INTERIM (flagged, none load-bearing): the multi-row `SkillRaceClassInfo` tie-break (first
|
||
//! admitting row wins — the client's `0x6ddf90` returns one; consistent for the lines that matter),
|
||
//! and the rank tie-break falling to the rank *string* only when the parsed number is 0 (the
|
||
//! client's `SpellLevel` fallback for a digit-less rank is unmodeled — we have no `SpellLevel`).
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_formats::{SkillLineCatalog, SpellCatalog};
|
||
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
|
||
|
||
use crate::entities::ItemDisplays;
|
||
use crate::items::Items;
|
||
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
|
||
use crate::ui_action::{
|
||
cast_target, melee_auto_attack_icon, ranged_weapon_icon, CastCommit, CastLadder, PlayerActions,
|
||
Spells,
|
||
};
|
||
use crate::ui_script::{gate, UiInput};
|
||
use crate::ui_unit::UnitFeed;
|
||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||
|
||
/// The skill-line catalog (`SkillLine.dbc` × `SkillLineAbility.dbc`) — absent when the client
|
||
/// data failed to load (every consumer tolerates that; `ui_action::Spells`' own precedent).
|
||
#[derive(Resource)]
|
||
pub(crate) struct SkillLines {
|
||
pub(crate) catalog: SkillLineCatalog,
|
||
}
|
||
|
||
/// The line id for a spell with no resolvable skill line — the **General tab, key 0**, which the
|
||
/// client pins FIRST in the tab strip and renders with a fixed name + icon (byte-verified: wow-re
|
||
/// spellbook-book-build.md, `GetSpellTabInfo 0x4b3ce0` — key 0 → the localized `GENERAL`
|
||
/// GlobalString name `0x846910` + the hardcoded icon `Interface\Icons\Ability_Kick` `0x8468f0`).
|
||
/// `0` is never a real vmangos `SkillType` (`SharedDefines.h`'s `SKILL_NONE = 0`), so it can't
|
||
/// collide with a real line id.
|
||
const NO_LINE: u32 = 0;
|
||
|
||
/// The General tab's icon — hardcoded in the client (`GetSpellTabInfo 0x4b3ce0`, `0x8468f0`), not
|
||
/// a `SkillLine.dbc` lookup (key 0 has no DBC row). Extensionless, as the DBC/BLP loader expects.
|
||
const GENERAL_TAB_ICON: &str = "Interface\\Icons\\Ability_Kick";
|
||
|
||
pub(crate) struct UiSpellbookPlugin;
|
||
|
||
impl Plugin for UiSpellbookPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.add_systems(Startup, load_skill_lines.after(AssetSet::Open))
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
// Feed rides with the unit feed like ui_action's own (before UiInput, so
|
||
// P-key-open-this-frame already sees a populated book); the cast drain runs
|
||
// after UiInput so a click's CastSpell goes out the same frame.
|
||
// `.before(CooldownEvents)`: the per-slot cooldown triples must be in the VM
|
||
// before `feed_action_state`'s synchronous `SPELL_UPDATE_COOLDOWN` makes the
|
||
// book buttons re-read them (that set's own doc).
|
||
feed_spellbook
|
||
.in_set(UnitFeed)
|
||
.before(crate::ui_action::CooldownEvents)
|
||
.before(UiInput),
|
||
drain_spell_casts.after(UiInput),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn load_skill_lines(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||
let Some(assets) = assets else { return };
|
||
let loaded = {
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_skill_line_catalog(&mut chain)
|
||
};
|
||
match loaded {
|
||
Ok(catalog) => {
|
||
info!(
|
||
"ui_spellbook: {} skill line(s) in the tab catalog",
|
||
catalog.len()
|
||
);
|
||
commands.insert_resource(SkillLines { catalog });
|
||
}
|
||
Err(e) => warn!(
|
||
"ui_spellbook: SkillLine.dbc failed to load — every spell falls into one \
|
||
fallback tab: {e:#}"
|
||
),
|
||
}
|
||
}
|
||
|
||
/// The feed's memory of what it last pushed, for the `SPELLS_CHANGED` diff.
|
||
#[derive(Default)]
|
||
struct FeedMemory {
|
||
pushed: SpellBookState,
|
||
/// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed`
|
||
/// (the weapon-icon template asks, the cooldown store's per-frame prune), plus the self
|
||
/// store's PRESENCE: the book's weapon icons flip on a despawn no `Changed` filter sees.
|
||
items_objects: gate::Watch,
|
||
items_templates: gate::Watch,
|
||
cooldown_epoch: gate::Watch,
|
||
self_present: gate::Watch,
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
|
||
fn feed_spellbook(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
actions: Res<PlayerActions>,
|
||
spells: Option<Res<Spells>>,
|
||
skill_lines: Option<Res<SkillLines>>,
|
||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
|
||
mut items: ResMut<Items>,
|
||
icons: Option<Res<ItemDisplays>>,
|
||
commands: Res<NetCommands>,
|
||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||
clock: Res<crate::ui_script::UiClock>,
|
||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let (memory, vm_reset) = memory.get_reset(&script);
|
||
// The gate (1439): the book is a function of the known-spell set, the two catalogs, the
|
||
// self descriptor (race/class, form byte, the two weapon slots — presence watched too,
|
||
// see the memory struct), the item stores behind the weapon icons, and the cooldown
|
||
// store. `UiClock` is deliberately NOT an input: `ui_triple` is frame-stable per arm and
|
||
// natural expiry rides the store's `feed_epoch`.
|
||
let objects_moved = memory.items_objects.moved(items.object_epoch());
|
||
let templates_moved = memory.items_templates.moved(items.template_epoch());
|
||
let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch());
|
||
let presence_moved = memory.self_present.moved(u64::from(!self_q.is_empty()));
|
||
// The frame a timer crosses zero, BEFORE the per-frame prune has moved the epoch
|
||
// (`sweep_pending`'s own doc) — the triple flips to None right then.
|
||
let sweep = cooldowns.sweep_pending(clock.anchor);
|
||
let self_changed = !changed_self.is_empty();
|
||
let actions_changed = actions.is_changed();
|
||
let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed());
|
||
let lines_changed = skill_lines.as_ref().is_some_and(|r| r.is_changed());
|
||
// `is_added` for the icon catalog: the feeds read only its load-once icon column;
|
||
// its model-cache half churns every frame (the containers gate's own note).
|
||
let icons_added = icons.as_ref().is_some_and(|r| r.is_added());
|
||
gate::trace(
|
||
"feed_spellbook",
|
||
&[
|
||
("vm_reset", vm_reset),
|
||
("objects", objects_moved),
|
||
("templates", templates_moved),
|
||
("cooldowns", cooldowns_moved),
|
||
("sweep", sweep),
|
||
("presence", presence_moved),
|
||
("self", self_changed),
|
||
("actions", actions_changed),
|
||
("spells", spells_changed),
|
||
("skill_lines", lines_changed),
|
||
("icons", icons_added),
|
||
],
|
||
);
|
||
let gate = gate::Gate::new(
|
||
vm_reset
|
||
|| objects_moved
|
||
|| templates_moved
|
||
|| cooldowns_moved
|
||
|| sweep
|
||
|| presence_moved
|
||
|| self_changed
|
||
|| actions_changed
|
||
|| spells_changed
|
||
|| lines_changed
|
||
|| icons_added,
|
||
);
|
||
if gate.skip() {
|
||
return;
|
||
}
|
||
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands.
|
||
let Some(spells) = spells.as_deref() else {
|
||
return;
|
||
};
|
||
// The player's race/class drive the General collapse (module doc). Absent (no self player
|
||
// yet), 0/0 skips the collapse — each line keeps its own tab until the descriptor arrives.
|
||
let store = self_q.single().ok();
|
||
let (race, class) = store
|
||
.map(|s| (s.0.unit_race().unwrap_or(0), s.0.unit_class().unwrap_or(0)))
|
||
.unwrap_or((0, 0));
|
||
// The melee auto-attack's icon is the equipped main-hand weapon (or Spell-Reset when unarmed),
|
||
// not spell 6603's `Temp` placeholder (decision 0230) — resolved here where the self player +
|
||
// item stores are in hand, once for the whole page (it's the same for any auto-attack spell).
|
||
let attack_icon = store
|
||
.map(|s| melee_auto_attack_icon(s, &spells.forms, &mut items, icons.as_deref(), &commands));
|
||
// The ranged auto-repeat shots (Auto Shot, wand Shoot) borrow the equipped ranged weapon's
|
||
// icon the same way (decision 0231's ranged case; `None` — unarmed/thrown — keeps the
|
||
// spell's own icon, never Spell-Reset). Character-level like the melee icon: one resolve
|
||
// serves the page.
|
||
let ranged_icon =
|
||
store.and_then(|s| ranged_weapon_icon(s, &mut items, icons.as_deref(), &commands));
|
||
let mut fresh = build_book(
|
||
&actions.spells,
|
||
&spells.catalog,
|
||
skill_lines.as_deref().map(|s| &s.catalog),
|
||
race,
|
||
class,
|
||
attack_icon,
|
||
ranged_icon,
|
||
);
|
||
// The `IsCurrentCast` verdict per slot — the delegate `0x4b3600` (wow-re
|
||
// `spellbook-checked-predicate.md`, §5-verified): its OWN function, NOT the action bar's
|
||
// `0x4e53a0`, and deliberately narrower — TWO arms only. The shapeshift arm is built here: a
|
||
// MOD_SHAPESHIFT spell reads current exactly while the player's form byte equals its form id
|
||
// (keyed on the FORM, so any spell/rank granting it reads true — Ghost Wolf's slot glows
|
||
// while shifted, a druid form's likewise). The other arm (the open trade-skill window) stays
|
||
// unmodeled with the trade-skill session itself. The action predicate's casting-now /
|
||
// awaiting-target / item / attack arms are ABSENT in the binary — a book slot must NOT light
|
||
// during an ordinary cast (the verdict's load-bearing negative).
|
||
let form_byte = store.map(|s| s.0.unit_shapeshift_form()).unwrap_or(0);
|
||
if form_byte != 0 {
|
||
for slot in &mut fresh.slots {
|
||
slot.current = spells
|
||
.catalog
|
||
.get(slot.spell_id)
|
||
.is_some_and(|d| d.shapeshift_form == Some(u32::from(form_byte)));
|
||
}
|
||
}
|
||
// Each slot's cooldown triple — the ONE store's per-spell read (`Cooldowns::info`, the
|
||
// `GetCooldownInfo 0x6e13e0` resolve: id, category, and GCD spread alike), converted to the
|
||
// GetTime clock exactly like the action/bag feeds (`CooldownInfo::ui_triple` is frame-stable
|
||
// per arm, so a running cooldown never churns this diff). The XML's SPELL_UPDATE_COOLDOWN
|
||
// handler re-reads these through `GetSpellCooldown` — the plugin's `.before(CooldownEvents)`
|
||
// guarantees they're pushed before that event fires.
|
||
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
|
||
for slot in &mut fresh.slots {
|
||
slot.cooldown = cooldowns
|
||
.info(slot.spell_id, 0, spells.catalog.get(slot.spell_id), anchor)
|
||
.ui_triple(anchor, ui_now);
|
||
}
|
||
if fresh != memory.pushed {
|
||
gate.audit("feed_spellbook", "the spellbook snapshot");
|
||
// Two event edges off one diff: the ref fires CURRENT_SPELL_CAST_CHANGED for a checked-
|
||
// ring move and SPELLS_CHANGED for the book itself; a same-frame both fires both.
|
||
let ring_moved = fresh.slots.len() != memory.pushed.slots.len()
|
||
|| fresh
|
||
.slots
|
||
.iter()
|
||
.zip(&memory.pushed.slots)
|
||
.any(|(a, b)| a.current != b.current);
|
||
let book_changed = fresh.tabs != memory.pushed.tabs
|
||
|| fresh.slots.len() != memory.pushed.slots.len()
|
||
|| fresh.slots.iter().zip(&memory.pushed.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)
|
||
});
|
||
debug!(
|
||
"ui_spellbook: fed {} tab(s), {} spell(s) (book {book_changed}, ring {ring_moved})",
|
||
fresh.tabs.len(),
|
||
fresh.slots.len()
|
||
);
|
||
script.set_spellbook(fresh.clone());
|
||
memory.pushed = fresh;
|
||
if book_changed {
|
||
script.fire_event("SPELLS_CHANGED", vec![]);
|
||
}
|
||
if ring_moved {
|
||
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build the whole book (module doc): the app's own resolve — the engine holds no spell
|
||
/// knowledge, only what's pushed here.
|
||
fn build_book(
|
||
known: &BTreeSet<u32>,
|
||
catalog: &SpellCatalog,
|
||
skill_lines: Option<&SkillLineCatalog>,
|
||
race: u8,
|
||
class: u8,
|
||
attack_icon: Option<String>,
|
||
ranged_icon: Option<String>,
|
||
) -> SpellBookState {
|
||
let mut by_line: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
|
||
for &spell_id in known {
|
||
// The byte-verified add-gate (module doc; `SpellDisplay::in_spellbook`): languages,
|
||
// armor/weapon proficiencies, hidden passives, tradeskills, and castUI spells never
|
||
// reach the book. A spell absent from `Spell.dbc` can't be classified or rendered, so it
|
||
// is dropped too (there is no name/icon/line to show).
|
||
if !catalog.get(spell_id).is_some_and(|d| d.in_spellbook()) {
|
||
continue;
|
||
}
|
||
// The tab is the spell's skill line routed through the race/class General collapse
|
||
// (module doc; `spell_tab` returns `NO_LINE`=0 for General). No skill-line catalog →
|
||
// General for everything (no line info at all).
|
||
let tab = skill_lines
|
||
.map(|c| c.spell_tab(spell_id, race, class))
|
||
.unwrap_or(NO_LINE);
|
||
by_line.entry(tab).or_default().push(spell_id);
|
||
}
|
||
|
||
// Resolve each line's display face, then order the TABS the client's way (byte-verified,
|
||
// module doc): General/key-0 pinned first, every other tab alphabetical by SkillLine.dbc
|
||
// name. Key 0 (the General tab) has no DBC row — it takes the client's own hardcoded
|
||
// name + icon; an unresolved REAL line id best-efforts to the same label but no icon.
|
||
let mut lines: Vec<(u32, String, Option<String>, Vec<u32>)> = by_line
|
||
.into_iter()
|
||
.map(|(line_id, spell_ids)| {
|
||
let (name, texture) = if line_id == NO_LINE {
|
||
("General".to_string(), Some(GENERAL_TAB_ICON.to_string()))
|
||
} else {
|
||
match skill_lines.and_then(|c| c.line(line_id)) {
|
||
Some(info) => (info.name.clone(), info.icon.clone()),
|
||
None => ("General".to_string(), None),
|
||
}
|
||
};
|
||
(line_id, name, texture, spell_ids)
|
||
})
|
||
.collect();
|
||
lines.sort_by(|a, b| (a.0 != NO_LINE).cmp(&(b.0 != NO_LINE)).then(a.1.cmp(&b.1)));
|
||
|
||
let mut tabs = Vec::with_capacity(lines.len());
|
||
let mut slots = Vec::new();
|
||
for (_, name, texture, mut spell_ids) in lines {
|
||
spell_ids.sort_by_key(|&a| spell_sort_key(catalog, a));
|
||
let offset = slots.len() as u32;
|
||
let num_spells = spell_ids.len() as u32;
|
||
for spell_id in spell_ids {
|
||
let d = catalog.get(spell_id);
|
||
// The melee auto-attack shows the equipped weapon / Spell-Reset, not spell 6603's
|
||
// `Temp` placeholder (decision 0231) — keyed on the effect type, the same substitution
|
||
// the action bar makes; `attack_icon` is `None` only with no character to read.
|
||
let texture = if d.is_some_and(|d| d.is_melee_auto_attack()) {
|
||
attack_icon
|
||
.clone()
|
||
.or_else(|| d.and_then(|d| d.icon.clone()))
|
||
} else if d.is_some_and(|d| d.ranged_icon_substitution()) {
|
||
// Auto Shot / wand Shoot borrow the ranged weapon's icon; absent one (or a
|
||
// thrown), the spell's own icon — the `0x4e6990` null-return hand-over.
|
||
ranged_icon
|
||
.clone()
|
||
.or_else(|| d.and_then(|d| d.icon.clone()))
|
||
} else {
|
||
d.and_then(|d| d.icon.clone())
|
||
};
|
||
slots.push(SpellSlotView {
|
||
spell_id,
|
||
name: d.map(|d| d.name.clone()).unwrap_or_default(),
|
||
rank: d.and_then(|d| d.rank.clone()),
|
||
texture,
|
||
passive: d.is_some_and(|d| d.passive),
|
||
// The IsCurrentCast verdict and the cooldown triple are stamped by the feed
|
||
// after the build (they need the live form/cooldown state, not the catalog).
|
||
current: false,
|
||
cooldown: None,
|
||
// Player-book slots: `GetSpellAutocast` answers (nil, nil) for this book by the
|
||
// reference's own short-circuit, and there is no packed word behind a known spell.
|
||
autocast: None,
|
||
packed: 0,
|
||
});
|
||
}
|
||
tabs.push(SpellTabView {
|
||
name,
|
||
texture,
|
||
offset,
|
||
num_spells,
|
||
});
|
||
}
|
||
SpellBookState { tabs, slots }
|
||
}
|
||
|
||
/// The book comparator `0x4b30c0`'s name → rank tail (module doc; the category-ordinal head is the
|
||
/// per-tab grouping itself): locale name, then the **parsed rank number** ascending, then the rank
|
||
/// string as the tie-break the client only reaches when the parsed number is 0. The client parses
|
||
/// the rank by scanning the first digit run of the `NameSubtext` string (`0x4b3c30`: `isdigit`,
|
||
/// `n = n*10 + digit`) — not `strip_prefix("Rank ")` — so this scans the same way, catching any
|
||
/// "Rank N"/localized-prefix form. (The client's `SpellLevel` fallback for a digit-less rank isn't
|
||
/// modeled — module doc.)
|
||
fn spell_sort_key(catalog: &SpellCatalog, spell_id: u32) -> (String, u32, String) {
|
||
let d = catalog.get(spell_id);
|
||
spell_sort_key_of(
|
||
d.map(|d| d.name.as_str()).unwrap_or_default(),
|
||
d.and_then(|d| d.rank.as_deref()),
|
||
)
|
||
}
|
||
|
||
/// [`spell_sort_key`] over an already-resolved `(name, rank)` — the **pet** book's entry point
|
||
/// (`crate::ui_pet_book`). The reference sorts both arrays with the same comparator `0x4b30c0`
|
||
/// (`0x4b2fd0` calls it for `0xb700f0` and `0xb6f098` alike), so the two books share this and not
|
||
/// a copy of it.
|
||
pub(crate) fn spell_sort_key_of(name: &str, rank: Option<&str>) -> (String, u32, String) {
|
||
let rank_str = rank.unwrap_or_default().to_string();
|
||
(name.to_string(), leading_number(&rank_str), rank_str)
|
||
}
|
||
|
||
/// The first run of ASCII digits in `s` as a number, or `0` when there is none — the client's own
|
||
/// rank parse (`0x4b3c30`, module doc): skip to the first digit, then fold the digit run.
|
||
fn leading_number(s: &str) -> u32 {
|
||
s.chars()
|
||
.skip_while(|c| !c.is_ascii_digit())
|
||
.take_while(|c| c.is_ascii_digit())
|
||
.fold(None::<u32>, |acc, c| {
|
||
Some(acc.unwrap_or(0) * 10 + c.to_digit(10).unwrap())
|
||
})
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Drain `take_spell_casts` through the SAME cast tail `drain_action_uses` uses for a SPELL-kind
|
||
/// action (decision 0216 §8: "root-cause rule: one cast-send path") — `ui_action::send_spell_cast`.
|
||
fn drain_spell_casts(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
targeting: cast_target::CastTargeting,
|
||
mut ladder: CastLadder,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for spell_id in script.take_spell_casts() {
|
||
// The `CastSpell` dispatcher's two press-again-to-cancel forks (`0x4b3300`, wow-re
|
||
// `shapeshift-plaincast-toggle.md`), in the ref's own order: the active-action toggle
|
||
// (`0x4b36f0` → cancel `0x4b3466`), then the shapeshift form-match fork
|
||
// (`0x4b348b`–`0x4b35e5`) with its non-cancelable silent no-op (`0x4b35cf`). This is the
|
||
// leg `UseAction` does NOT have — a druid's `/cast Cat Form` powershift-out and a
|
||
// shaman's Ghost Wolf re-cast both land here.
|
||
if let (Some(sp), Some(store)) =
|
||
(ladder.spells.as_ref(), targeting.self_store.iter().next())
|
||
{
|
||
if let Some(d) = sp.catalog.get(spell_id) {
|
||
if crate::ui_action::toggle::active_action_toggle(spell_id, d, store) {
|
||
debug!("ui_spellbook: cast {spell_id} re-pressed — aura cancels");
|
||
let _ = ladder
|
||
.commands
|
||
.0
|
||
.send(crate::net::ClientCommand::CancelAura { spell_id });
|
||
continue;
|
||
}
|
||
let form = store.0.unit_shapeshift_form();
|
||
let row = sp.forms.get(&u32::from(form));
|
||
match crate::ui_action::toggle::form_recast_disposition(d, form, row) {
|
||
Some(true) => {
|
||
debug!("ui_spellbook: cast {spell_id} — the active form cancels");
|
||
let _ = ladder
|
||
.commands
|
||
.0
|
||
.send(crate::net::ClientCommand::CancelAura { spell_id });
|
||
continue;
|
||
}
|
||
Some(false) => {
|
||
debug!("ui_spellbook: cast {spell_id} — non-cancelable form, silent no-op");
|
||
continue;
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
}
|
||
debug!(
|
||
"ui_spellbook: cast {spell_id} (target {:?})",
|
||
targeting.selection.guid
|
||
);
|
||
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use benilla_formats::SpellDisplay;
|
||
use std::collections::HashMap;
|
||
|
||
#[test]
|
||
fn leading_number_scans_the_first_digit_run() {
|
||
assert_eq!(leading_number("Rank 1"), 1);
|
||
assert_eq!(leading_number("Rank 10"), 10);
|
||
assert_eq!(leading_number("Rank 2"), 2);
|
||
assert_eq!(leading_number(""), 0);
|
||
assert_eq!(leading_number("Racial"), 0); // digit-less → 0 (client falls to SpellLevel)
|
||
assert_eq!(leading_number("Apprentice"), 0);
|
||
}
|
||
|
||
/// A minimal `SpellDisplay`: `attributes`/`cast_ui` drive the gate, `rank` the sort.
|
||
fn spell(name: &str, rank: Option<&str>, attributes: u32) -> SpellDisplay {
|
||
SpellDisplay {
|
||
name: name.into(),
|
||
rank: rank.map(Into::into),
|
||
attributes,
|
||
passive: attributes & 0x40 != 0,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// The add-gate + General tab through `build_book`, no skill lines (every shown spell lands
|
||
/// in the pinned General tab): hidden spells (DO_NOT_DISPLAY / IS_TRADESKILL) never appear,
|
||
/// shown spells sort name→rank, and the General tab takes the client's hardcoded name + icon.
|
||
#[test]
|
||
fn build_book_gates_hidden_spells_and_pins_general() {
|
||
let mut map = HashMap::new();
|
||
map.insert(133, spell("Fireball", Some("Rank 2"), 0x10000));
|
||
map.insert(145, spell("Fireball", Some("Rank 1"), 0x10000)); // out-of-order rank
|
||
map.insert(2136, spell("Fire Blast", Some("Rank 1"), 0x0));
|
||
map.insert(668, spell("Language: Common", None, 0x80)); // DO_NOT_DISPLAY → hidden
|
||
map.insert(818, spell("Cooking", None, 0x20)); // IS_TRADESKILL → hidden
|
||
let catalog = SpellCatalog::from_displays(map);
|
||
let known: BTreeSet<u32> = [133, 145, 2136, 668, 818].into_iter().collect();
|
||
|
||
// No skill-line catalog → every shown spell lands in General (race/class irrelevant).
|
||
let book = build_book(&known, &catalog, None, 1, 1, None, None);
|
||
|
||
// One General tab, hardcoded face; only the three shown spells.
|
||
assert_eq!(book.tabs.len(), 1);
|
||
assert_eq!(book.tabs[0].name, "General");
|
||
assert_eq!(book.tabs[0].texture.as_deref(), Some(GENERAL_TAB_ICON));
|
||
assert_eq!(book.tabs[0].num_spells, 3);
|
||
// The hidden pair is gone; the shown spells sort name A-Z then rank ascending.
|
||
let order: Vec<(&str, Option<&str>)> = book
|
||
.slots
|
||
.iter()
|
||
.map(|s| (s.name.as_str(), s.rank.as_deref()))
|
||
.collect();
|
||
assert_eq!(
|
||
order,
|
||
vec![
|
||
("Fire Blast", Some("Rank 1")),
|
||
("Fireball", Some("Rank 1")),
|
||
("Fireball", Some("Rank 2")),
|
||
]
|
||
);
|
||
}
|
||
|
||
/// The melee auto-attack (keyed on `Effect[0] == SPELL_EFFECT_ATTACK`, not the id) shows the
|
||
/// pre-resolved character icon `build_book` is handed, never spell 6603's `Temp` placeholder
|
||
/// (decision 0231). `attack_icon` carries the weapon-or-Spell-Reset resolution done in the feed;
|
||
/// `None` (no character to read) falls back to the spell's own icon.
|
||
#[test]
|
||
fn build_book_attack_shows_the_resolved_icon_by_effect() {
|
||
const ATTACK: u32 = 6603;
|
||
let mut attack = spell("Attack", None, 0x10);
|
||
attack.icon = Some("Interface\\Icons\\Temp".into()); // the real DBC placeholder
|
||
attack.effects[0] = 78; // SPELL_EFFECT_ATTACK — makes it the melee auto-attack
|
||
let catalog = SpellCatalog::from_displays(HashMap::from([(ATTACK, attack)]));
|
||
let known: BTreeSet<u32> = [ATTACK].into_iter().collect();
|
||
|
||
// The feed's resolved icon (armed → the weapon, unarmed → Spell-Reset) wins over Temp.
|
||
for resolved in [
|
||
"Interface\\Icons\\INV_Sword_04",
|
||
"Interface\\Buttons\\Spell-Reset",
|
||
] {
|
||
let book = build_book(&known, &catalog, None, 1, 1, Some(resolved.into()), None);
|
||
assert_eq!(book.slots.len(), 1);
|
||
assert_eq!(book.slots[0].spell_id, ATTACK);
|
||
assert_eq!(book.slots[0].texture.as_deref(), Some(resolved));
|
||
}
|
||
|
||
// No character to read (attack_icon None): falls back to the spell's own icon.
|
||
let bare = build_book(&known, &catalog, None, 1, 1, None, None);
|
||
assert_eq!(
|
||
bare.slots[0].texture.as_deref(),
|
||
Some("Interface\\Icons\\Temp")
|
||
);
|
||
}
|
||
|
||
/// The ranged auto-repeat shots (both bits: `Attributes & 0x2` + `AttributesEx2 & 0x20`)
|
||
/// show the pre-resolved ranged weapon icon; without one they keep the spell's OWN icon —
|
||
/// never `Spell-Reset` (the `0x4e6990` helper's null-return hand-over, decision 0231's
|
||
/// ranged case). A ranged-slot-only spell (Throw's `0x2` alone) never substitutes.
|
||
#[test]
|
||
fn build_book_ranged_shots_borrow_the_ranged_weapon_icon() {
|
||
const AUTO_SHOT: u32 = 75;
|
||
const THROW: u32 = 2764;
|
||
let mut auto_shot = spell("Auto Shot", None, 0x2);
|
||
auto_shot.icon = Some("Interface\\Icons\\Ability_AutoShot".into());
|
||
auto_shot.attributes_ex2 = 0x20;
|
||
let mut throw = spell("Throw", None, 0x2);
|
||
throw.icon = Some("Interface\\Icons\\Ability_Throw".into());
|
||
let catalog =
|
||
SpellCatalog::from_displays(HashMap::from([(AUTO_SHOT, auto_shot), (THROW, throw)]));
|
||
let known: BTreeSet<u32> = [AUTO_SHOT, THROW].into_iter().collect();
|
||
|
||
let bow = "Interface\\Icons\\INV_Weapon_Bow_02";
|
||
let book = build_book(&known, &catalog, None, 1, 1, None, Some(bow.into()));
|
||
let icon_of = |b: &SpellBookState, id: u32| {
|
||
b.slots
|
||
.iter()
|
||
.find(|s| s.spell_id == id)
|
||
.and_then(|s| s.texture.clone())
|
||
};
|
||
assert_eq!(icon_of(&book, AUTO_SHOT).as_deref(), Some(bow));
|
||
assert_eq!(
|
||
icon_of(&book, THROW).as_deref(),
|
||
Some("Interface\\Icons\\Ability_Throw"),
|
||
"ranged-slot alone (no auto-repeat bit) keeps the spell icon"
|
||
);
|
||
|
||
// No ranged weapon: Auto Shot keeps its own icon (no Spell-Reset on the ranged path).
|
||
let bare = build_book(&known, &catalog, None, 1, 1, None, None);
|
||
assert_eq!(
|
||
icon_of(&bare, AUTO_SHOT).as_deref(),
|
||
Some("Interface\\Icons\\Ability_AutoShot")
|
||
);
|
||
}
|
||
}
|