Files
benilla-octo/crates/benilla/src/ui_spellbook.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

472 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, HashSet};
use bevy::prelude::*;
use benilla_formats::{SkillLineCatalog, SpellCatalog};
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
use crate::assets::{AssetSet, LockRecover, WorldAssets};
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, send_spell_cast, PlayerActions, Spells,
};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// 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.
feed_spellbook.in_set(UnitFeed).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,
}
#[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>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
mut memory: Local<FeedMemory>,
) {
let Some(mut script) = script else {
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, &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 fresh = build_book(
&actions.spells,
&spells.catalog,
skill_lines.as_deref().map(|s| &s.catalog),
race,
class,
attack_icon,
ranged_icon,
);
if fresh != memory.pushed {
debug!(
"ui_spellbook: fed {} tab(s), {} spell(s)",
fresh.tabs.len(),
fresh.slots.len()
);
script.set_spellbook(fresh.clone());
memory.pushed = fresh;
script.fire_event("SPELLS_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: &HashSet<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),
});
}
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);
let name = d.map(|d| d.name.clone()).unwrap_or_default();
let rank_str = d.and_then(|d| d.rank.clone()).unwrap_or_default();
(name, 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`.
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
fn drain_spell_casts(
script: Option<NonSendMut<UiScript>>,
targeting: cast_target::CastTargeting,
commands: Res<NetCommands>,
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
spells: Option<Res<Spells>>,
cast_items: Res<Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
mut pending: ResMut<crate::ui_cast::PendingCast>,
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
mut ecs: Commands,
) {
let Some(mut script) = script else {
return;
};
for spell_id in script.take_spell_casts() {
debug!(
"ui_spellbook: cast {spell_id} (target {:?})",
targeting.selection.guid
);
send_spell_cast(
spell_id,
&targeting.context(),
&commands,
&self_player,
spells.as_deref(),
&cast_items,
&mut sheath,
&mut ecs,
&mut pending,
&mut queued_melee,
&mut cooldowns,
&mut cast_errors,
&mut auto_repeat,
&mut trade_skill_opens,
);
}
}
#[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: HashSet<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.effect_1 = 78; // SPELL_EFFECT_ATTACK — makes it the melee auto-attack
let catalog = SpellCatalog::from_displays(HashMap::from([(ATTACK, attack)]));
let known: HashSet<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: HashSet<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")
);
}
}