//! `Talent.dbc` + `TalentTab.dbc` loader — the vanilla talent trees' data plane (decision 0304):
//! each class's three tabs, and each tab's talents with grid position, rank spells, and
//! prerequisites. The window ([`benilla`]'s ui_talent) renders these; the learn click sends
//! `CMSG_LEARN_TALENT` with a row id from here.
//!
//! Layout — VERIFIED against the **vmangos server source** (`DBCStructure.h`'s
//! `TalentEntry`/`TalentTabEntry` + `DBCfmt.h`'s format strings), the same standing as
//! [`crate::skill_lines`]: vmangos parses these tables off the same build-5875 client data.
//!
//! `Talent.dbc` — `TalentEntryfmt = "niiiiiiiixxxxixxixxxi"` (21 fields, 84 B/record):
//! `id`(0, indexed) · **`tabId` = column 1** (a `TalentTab.dbc` id) · **`row` = column 2**
//! (tier, 0-based) · **`col` = column 3** (0-based) · **`rankSpell[5]` = columns 4–8** (the
//! spell taught per rank; 0 = the talent has fewer ranks; columns 9–12 are the enum's unused
//! rank slots) · **`prereqTalent` = column 13** (14–15 unused prereq slots) · **`prereqRank` =
//! column 16** (0-based; 17–18 unused) · **flags = column 19** (bit 0 = the Lua API's
//! `isExceptional` — the client's `TalentRec+0x4c` bit0 read, wow-re `ui/scratch/talent-api.md`)
//! · **`requiredSpell` = column 20**.
//!
//! `TalentTab.dbc` — `TalentTabEntryfmt = "nxxxxxxxxxxxiix"` (15 fields, 60 B/record):
//! `id`(0, indexed) · the 8-locale `name_lang` block (1..8, enUS first ⇒ **NameEnUs = column
//! 1**) + its flags word (9) · `spellIcon`(10, unused by the 1.12 window) · **`raceMask` =
//! column 11** (511 = all races this build) · **`classMask` = column 12** · `orderIndex`(13 —
//! **never read by the client**: the shipped data ties the mage's Arcane/Fire at 0, raw-dump
//! verified) · **`backgroundFile` = column 14** (a string: the
//! `Interface\TalentFrame\-{TopLeft,TopRight,BottomLeft,BottomRight}` art base).
//!
//! **Order is the law here** (byte-verified — the 0305 fold-back, wow-re `talent-api.md`):
//! `GetTalentInfo(tab, index)` indexes the tab's talents by **native DBC row order**
//! (`TalentTabInfo[+0xC] + (index−1)*0x54`, a contiguous per-tab block — never a (tier, column)
//! re-sort), and a class's tabs come in **raw file order** among rows matching BOTH raceMask and
//! classMask (`0x4f2e50`). The shipped 1.12 data happens to author every tab's block in
//! (tier, column) order (27/27 tabs) — a coincidence the real-data test documents, not a rule
//! this reader imposes.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
const TALENT: &str = "DBFilesClient\\Talent.dbc";
const TALENT_TAB: &str = "DBFilesClient\\TalentTab.dbc";
const TALENT_FIELDS: usize = 21;
const COL_T_TAB: usize = 1;
const COL_T_ROW: usize = 2;
const COL_T_COL: usize = 3;
const COL_T_RANK0: usize = 4; // ..=8, five rank spells
const COL_T_PREREQ: usize = 13;
const COL_T_PREREQ_RANK: usize = 16;
const COL_T_FLAGS: usize = 19;
const COL_T_REQUIRED_SPELL: usize = 20;
const TALENT_TAB_FIELDS: usize = 15;
const COL_TT_NAME_ENUS: usize = 1;
const COL_TT_RACE_MASK: usize = 11;
const COL_TT_CLASS_MASK: usize = 12;
const COL_TT_BACKGROUND: usize = 14;
/// The vanilla rank ceiling (`MAX_TALENT_RANK`, vmangos `DBCStructure.h`).
pub const MAX_TALENT_RANK: usize = 5;
/// One `Talent.dbc` row — a talent's grid seat, rank spells, and prerequisites.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Talent {
pub id: u32,
/// The owning `TalentTab.dbc` id.
pub tab: u32,
/// Tier, 0-based (the reference grid draws it 1-based).
pub row: u32,
/// Column, 0-based (grid width is 4).
pub col: u32,
/// The spell taught at each rank; a talent with `n` ranks fills exactly the first `n`.
pub ranks: [u32; MAX_TALENT_RANK],
/// Prerequisite talent id (0 = none) + the rank (0-based) it must be learned to. In the
/// shipped 1.12 data every prerequisite lives in the same tab (asserted by the real-data
/// test) — the reference frame only ever draws same-tab arrows.
pub prereq_talent: u32,
pub prereq_rank: u32,
/// A spell that must be known regardless of talents (0 = none).
pub required_spell: u32,
/// Flags bit 0 — the Lua API's `isExceptional` (an activatable-ability talent; the
/// client's tooltip builder branches on it).
pub exceptional: bool,
}
impl Talent {
/// How many ranks this talent actually has (the count of populated rank spells).
pub fn max_rank(&self) -> u32 {
self.ranks.iter().take_while(|&&s| s != 0).count() as u32
}
}
/// One `TalentTab.dbc` row — a class page's identity.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TalentTabInfo {
pub id: u32,
pub name: String,
/// `raceMask`/`classMask` — bit `1 << (race/class − 1)`; the client admits a tab only when
/// BOTH match (511 = every race in the shipped data).
pub race_mask: u32,
pub class_mask: u32,
/// The `Interface\TalentFrame\-…` art base ("MageFire", …).
pub background: String,
}
/// The joined talent data plane: tabs by class, talents by tab.
pub struct TalentCatalog {
tabs: Vec,
/// tab id → its talents in **native DBC row order** — the byte-verified enumeration
/// `GetTalentInfo` indexes by (module doc; never re-sorted).
by_tab: HashMap>,
}
impl TalentCatalog {
/// A race+class's tabs (unit bytes, 1-based) in **raw file order** — the byte-verified tab
/// law (module doc: `0x4f2e50` filters on BOTH masks and never reads `orderIndex`; file
/// order alone reproduces the canonical Arcane/Fire/Frost page order).
pub fn tabs_for_class(&self, race: u8, class: u8) -> Vec<&TalentTabInfo> {
if !(1..=32).contains(&class) || !(1..=32).contains(&race) {
return Vec::new();
}
let race_bit = 1u32 << (race - 1);
let class_bit = 1u32 << (class - 1);
self.tabs
.iter()
.filter(|t| t.class_mask & class_bit != 0 && t.race_mask & race_bit != 0)
.collect()
}
/// A tab's talents in native DBC row order — the display/index order (byte-verified, see
/// [`TalentCatalog::by_tab`]).
pub fn talents_in_tab(&self, tab_id: u32) -> &[Talent] {
self.by_tab.get(&tab_id).map(Vec::as_slice).unwrap_or(&[])
}
/// One talent by id (the prereq-arrow resolve).
pub fn talent(&self, tab_id: u32, talent_id: u32) -> Option<&Talent> {
self.talents_in_tab(tab_id)
.iter()
.find(|t| t.id == talent_id)
}
pub fn len(&self) -> usize {
self.by_tab.values().map(Vec::len).sum()
}
pub fn is_empty(&self) -> bool {
self.by_tab.is_empty()
}
}
fn talent_schema() -> Schema {
let mut s = Schema::new("Talent");
for i in 0..TALENT_FIELDS {
s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32));
}
s
}
fn talent_tab_schema() -> Schema {
let mut s = Schema::new("TalentTab");
for i in 0..TALENT_TAB_FIELDS {
if i == COL_TT_NAME_ENUS {
s.add_field(SchemaField::new("NameEnUs", FieldType::String));
} else if i == COL_TT_BACKGROUND {
s.add_field(SchemaField::new("BackgroundFile", FieldType::String));
} else {
s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32));
}
}
s
}
/// Load the talent data plane off the patch chain.
pub fn load_talent_catalog(chain: &mut Chain) -> Result {
let tab_bytes = chain
.read_file(TALENT_TAB)
.context("reading TalentTab.dbc")?;
let tab_set = parse(&tab_bytes, talent_tab_schema(), "TalentTab.dbc")?;
let mut tabs = Vec::new();
for r in tab_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
tabs.push(TalentTabInfo {
id,
name: str_at(&tab_set, r, COL_TT_NAME_ENUS).unwrap_or_default(),
race_mask: u32_at(r, COL_TT_RACE_MASK).unwrap_or(0),
class_mask: u32_at(r, COL_TT_CLASS_MASK).unwrap_or(0),
background: str_at(&tab_set, r, COL_TT_BACKGROUND).unwrap_or_default(),
});
}
let t_bytes = chain.read_file(TALENT).context("reading Talent.dbc")?;
let t_set = parse(&t_bytes, talent_schema(), "Talent.dbc")?;
let mut by_tab: HashMap> = HashMap::new();
for r in t_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
let mut ranks = [0u32; MAX_TALENT_RANK];
for (i, rank) in ranks.iter_mut().enumerate() {
*rank = u32_at(r, COL_T_RANK0 + i).unwrap_or(0);
}
let t = Talent {
id,
tab: u32_at(r, COL_T_TAB).unwrap_or(0),
row: u32_at(r, COL_T_ROW).unwrap_or(0),
col: u32_at(r, COL_T_COL).unwrap_or(0),
ranks,
prereq_talent: u32_at(r, COL_T_PREREQ).unwrap_or(0),
prereq_rank: u32_at(r, COL_T_PREREQ_RANK).unwrap_or(0),
required_spell: u32_at(r, COL_T_REQUIRED_SPELL).unwrap_or(0),
exceptional: u32_at(r, COL_T_FLAGS).unwrap_or(0) & 1 != 0,
};
// Native row order preserved — the enumeration law (module doc); never sorted.
by_tab.entry(t.tab).or_default().push(t);
}
Ok(TalentCatalog { tabs, by_tab })
}
#[cfg(test)]
mod tests {
use super::*;
/// The real build-5875 tables, structurally (skips without client data): the mage's three
/// canonical pages in order with their art bases, and the whole table's grid/rank/prereq
/// invariants — every talent seats inside the 4-wide/≤8-tier grid, rank arrays are
/// prefix-shaped, and every prerequisite resolves within its own tab (the reference frame
/// only draws same-tab arrows, so cross-tab data would falsify the renderer's assumption).
#[test]
fn real_talent_tables_hold_the_grid_and_prereq_invariants() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_talent_catalog(&mut chain).expect("load Talent/TalentTab");
assert!(
cat.len() > 300,
"the 1.12 talent table has hundreds of rows, got {}",
cat.len()
);
// The mage pages (class 8): Arcane, Fire, Frost in raw file order (the byte-verified
// tab law — orderIndex is dead data), with their art bases.
const HUMAN: u8 = 1;
const MAGE: u8 = 8;
let tabs = cat.tabs_for_class(HUMAN, MAGE);
assert_eq!(
tabs.iter().map(|t| t.name.as_str()).collect::>(),
vec!["Arcane", "Fire", "Frost"]
);
assert_eq!(
tabs.iter()
.map(|t| t.background.as_str())
.collect::>(),
vec!["MageArcane", "MageFire", "MageFrost"]
);
// Every tab admits all races this build (raceMask 511) — the both-masks filter is
// exercised structurally: a race bit outside 511 admits nothing.
assert!(
cat.tabs_for_class(10, MAGE).is_empty(),
"race bit 10 is outside 511"
);
// Every class (unit class bytes 1..=9, no class 10 in vanilla, druid = 11) has pages.
for class in [1u8, 2, 3, 4, 5, 7, 8, 9, 11] {
let n = cat.tabs_for_class(HUMAN, class).len();
assert_eq!(n, 3, "class {class} has three talent pages, got {n}");
}
for tabs in cat.tabs_for_class(HUMAN, MAGE) {
let talents = cat.talents_in_tab(tabs.id);
assert!(!talents.is_empty());
// The shipped data authors every tab's NATIVE row order in (row, col) order — the
// 27/27 coincidence the module doc records (the reader imposes no sort; if a future
// patch chain broke this, the client (and we) would show the authored order).
let seats: Vec<(u32, u32)> = talents.iter().map(|t| (t.row, t.col)).collect();
let mut sorted = seats.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
seats, sorted,
"the shipped data's (row, col)-ordered authoring"
);
}
// Whole-table invariants.
let all_ids: std::collections::HashSet =
cat.by_tab.values().flatten().map(|t| t.id).collect();
for t in cat.by_tab.values().flatten() {
assert!(
t.col < 4,
"talent {} col {} inside the 4-wide grid",
t.id,
t.col
);
assert!(t.row < 8, "talent {} row {} inside 8 tiers", t.id, t.row);
let n = t.max_rank() as usize;
assert!(n >= 1, "talent {} has at least one rank", t.id);
assert!(
t.ranks[n..].iter().all(|&s| s == 0),
"talent {} rank array is prefix-shaped",
t.id
);
if t.prereq_talent != 0 {
assert!(
all_ids.contains(&t.prereq_talent),
"talent {} prereq {} resolves",
t.id,
t.prereq_talent
);
assert!(
cat.talent(t.tab, t.prereq_talent).is_some(),
"talent {} prereq {} lives in the same tab",
t.id,
t.prereq_talent
);
assert!(
(t.prereq_rank as usize) < MAX_TALENT_RANK,
"talent {} prereq rank {} is 0-based sane",
t.id,
t.prereq_rank
);
}
}
}
}