Files
benilla-octo/crates/benilla-ui/src/script/talent.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

277 lines
12 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 talent window's engine seam (decision 0304) — the same two-way shape as
//! [`super::spellbook`]: the app pushes a **talent snapshot** ([`UiScript::set_talents`] — the
//! class's tabs + each tab's talents, already resolved to name/icon/rank/availability by the
//! app's `Talent.dbc` × `Spell.dbc` join), the Era bindings below read it verbatim, and
//! `LearnTalent(tab, index)` queues outbound intents the app drains
//! ([`UiScript::take_talent_learns`]) into `CMSG_LEARN_TALENT`. The engine holds no talent
//! KNOWLEDGE — grid seats, ranks, prerequisites, and availability are the app's resolve.
//!
//! The Era tuple shapes are the 1.12 addon's own reads (`Blizzard_TalentUI.lua`, extracted from
//! the patch chain — decision 0304's pin §3):
//! `GetTalentTabInfo(i) → name, texture, pointsSpent, fileName`;
//! `GetTalentInfo(tab, i) → name, icon, tier, column, rank, maxRank, isExceptional, meetsPrereq`;
//! `GetTalentPrereqs(tab, i) → tier, column, isLearnable, …` (flat triplets);
//! `UnitCharacterPoints(unit) → cp1, cp2`. Tiers/columns are **1-based** Lua-facing (the
//! reference indexes `TALENT_BRANCH_ARRAY[tier][column]` directly) — the app pushes them
//! 1-based; `isExceptional` is pushed but unused by the reference frame's own render.
//!
//! ## The tooltip (`GameTooltip:SetTalent(tab, index)`)
//!
//! The 1.12 talent tooltip IS the spell builder `0x52e610` with the talent params (wow-re
//! `tooltip-content-law.md` §3: line 2 `TOOLTIP_TALENT_RANK` "Rank %d/%d" white iff
//! `param7≠0 && param8==0`; line 13 `TOOLTIP_TALENT_LEARN` "Click to learn" green on a learnable
//! higher rank) — so `SetTalent` renders THROUGH the spell channel: the talent's display-rank
//! spell view comes from the same ask-once store `SetSpell` uses (a miss queues the id for the
//! app's resolver), interleaved with the talent lines ([`TalentLines`]). The red requirement
//! lines' position (here: after the rank line) is CONFIRMED — decision 0305's residue: it
//! matches the builder law. Still open: the "Next rank:" block's composition (here:
//! `TOOLTIP_TALENT_NEXT_RANK` white + the next rank spell's description gold — its view from the
//! same store) — 0305 didn't walk it.
use mlua::{Lua, MultiValue, Table, Value};
use super::tooltip_spell::{spell_view_of, TalentLines};
use super::Model;
/// One talent page (`TalentTab.dbc` row, app-resolved) — `GetTalentTabInfo`'s source.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TalentTabView {
pub name: String,
/// The `Interface\TalentFrame\<base>-` art base (`fileName` in the Era tuple).
pub background: String,
/// Points spent across this page's talents (the app's sum of ranks).
pub points_spent: u32,
}
/// One prerequisite edge for the branch drawing — `GetTalentPrereqs`' triplet.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TalentPrereqView {
/// 1-based, Lua-facing (module doc).
pub tier: u32,
pub column: u32,
/// The prereq is learned to its required rank (drives arrow color + availability).
pub learnable: bool,
}
/// One talent button's full render state — `GetTalentInfo`'s source plus the tooltip context.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TalentView {
pub name: String,
pub texture: Option<String>,
/// 1-based, Lua-facing (module doc).
pub tier: u32,
pub column: u32,
/// Current rank (0 = unlearned) / the talent's own rank count.
pub rank: u32,
pub max_rank: u32,
/// `Talent.dbc` flags bit 0 — the Era tuple's `isExceptional` (byte-verified
/// `TalentRec+0x4c` bit0; the reference frame reads and ignores it).
pub exceptional: bool,
/// The non-prereq requirements hold — the required-spell known-check only, prereqs live in
/// the triplets (decision 0305: `meetsPrereq`'s derivation, confirmed as built).
pub meets_prereq: bool,
pub prereqs: Vec<TalentPrereqView>,
/// The tooltip's spell part: the display rank's spell id (rank max(1, rank)) — its view
/// rides the spell channel's ask-once store.
pub display_spell: u32,
/// The next rank's spell id when `0 < rank < max_rank` (the "Next rank:" block); 0 = none.
pub next_spell: u32,
/// App-composed red requirement lines (`TOOLTIP_TALENT_TIER_POINTS`/`_PREREQ[_P1]`), shown
/// while the talent is locked.
pub req_lines: Vec<String>,
/// The green `TOOLTIP_TALENT_LEARN` hint: a learnable higher rank (tier unlocked, prereqs
/// met, points available) — the same gate the frame's green border wears.
pub learnable: bool,
}
/// The pushed snapshot: the player's own pages + unspent points.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TalentUiState {
pub tabs: Vec<TalentTabView>,
/// `talents[t]` = tab `t+1`'s talents, in the enumeration order the app pinned.
pub talents: Vec<Vec<TalentView>>,
/// `UnitCharacterPoints("player")`: (unspent talent points, free primary professions).
pub points: (u32, u32),
}
impl super::UiScript {
/// Push the whole talent snapshot (the app's feed on SPELLS_CHANGED/points change).
pub fn set_talents(&mut self, state: TalentUiState) {
self.model_mut().talents = state;
}
/// Drain the queued `LearnTalent(tab, index)` clicks (both 1-based, as the Lua passed them).
pub fn take_talent_learns(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().talent_learns)
}
}
/// Look up one talent by the Lua-facing (tab, index) pair (both 1-based).
fn talent_at(model: &Model, tab: usize, index: usize) -> Option<&TalentView> {
model
.talents
.talents
.get(tab.checked_sub(1)?)
.and_then(|t| t.get(index.checked_sub(1)?))
}
/// Register the talent globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"GetNumTalentTabs",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.talents.tabs.len() as i64)
})?,
)?;
// GetTalentTabInfo(i) -> name, texture, pointsSpent, fileName. `texture` is nil — the
// reference frame never reads it (module doc); out of range -> a single nil.
g.set(
"GetTalentTabInfo",
lua.create_function(|lua, i: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(tab) = i.checked_sub(1).and_then(|n| model.talents.tabs.get(n)) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&tab.name)?),
Value::Nil,
Value::Integer(i64::from(tab.points_spent)),
Value::String(lua.create_string(&tab.background)?),
]))
})?,
)?;
g.set(
"GetNumTalents",
lua.create_function(|lua, tab: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let n = tab
.checked_sub(1)
.and_then(|t| model.talents.talents.get(t))
.map_or(0, Vec::len);
Ok(n as i64)
})?,
)?;
// GetTalentInfo(tab, i) -> name, icon, tier, column, rank, maxRank, isExceptional,
// meetsPrereq. `isExceptional` is 0 (pushed nowhere; the reference render ignores it).
g.set(
"GetTalentInfo",
lua.create_function(|lua, (tab, i): (usize, usize)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(t) = talent_at(&model, tab, i) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let texture = match &t.texture {
Some(tex) => Value::String(lua.create_string(tex)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&t.name)?),
texture,
Value::Integer(i64::from(t.tier)),
Value::Integer(i64::from(t.column)),
Value::Integer(i64::from(t.rank)),
Value::Integer(i64::from(t.max_rank)),
Value::Integer(i64::from(t.exceptional)),
Value::Boolean(t.meets_prereq),
]))
})?,
)?;
// GetTalentPrereqs(tab, i) -> tier, column, isLearnable per prerequisite, flattened (the
// reference walks `for i=5, arg.n, 3`); none -> empty.
g.set(
"GetTalentPrereqs",
lua.create_function(|lua, (tab, i): (usize, usize)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
if let Some(t) = talent_at(&model, tab, i) {
for p in &t.prereqs {
out.push(Value::Integer(i64::from(p.tier)));
out.push(Value::Integer(i64::from(p.column)));
out.push(Value::Boolean(p.learnable));
}
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// LearnTalent(tab, i): queue the click for the app's wire drain. The availability gate is
// the app's at send time (mirroring its own pushed `learnable`) — the server re-validates
// regardless (vmangos Player::LearnTalent).
g.set(
"LearnTalent",
lua.create_function(|lua, (tab, i): (u32, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.talent_learns.push((tab, i));
Ok(())
})?,
)?;
// UnitCharacterPoints(unit) -> cp1 (unspent talent points), cp2 (free professions). The
// pair is our own player's (PLAYER_CHARACTER_POINTS1/2 are PRIVATE fields) — any other
// unit token answers the same store, matching the fields only we ever receive.
g.set(
"UnitCharacterPoints",
lua.create_function(|lua, _unit: String| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (cp1, cp2) = model.talents.points;
Ok((i64::from(cp1), i64::from(cp2)))
})?,
)?;
Ok(())
}
/// Register `GameTooltip:SetTalent(tab, index)` into the tooltip kind method table (module doc:
/// the spell builder with talent lines).
pub(super) fn install_tooltip_method(lua: &Lua, m: &Table) -> mlua::Result<()> {
m.set(
"SetTalent",
lua.create_function(|lua, (this, tab, i): (Table, usize, usize)| {
let (display, lines) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(t) = talent_at(&model, tab, i) else {
return Ok(());
};
// The "Next rank:" block needs the next rank's description — from the same
// ask-once spell store (a miss shows the block on the hover's re-enter).
let next_desc = (t.next_spell != 0)
.then(|| {
model
.spell_tooltips
.get(&t.next_spell)
.map(|v| v.description.clone())
})
.flatten();
(
t.display_spell,
TalentLines {
rank_line: format!("Rank {}/{}", t.rank, t.max_rank),
reqs: t.req_lines.clone(),
next_spell: t.next_spell,
next_desc,
learn: t.learnable,
},
)
};
super::tooltip_spell::set_spell_with_talent(lua, &this, display, lines)
})?,
)?;
Ok(())
}
/// The spell-store ask for the next-rank description (kept beside [`install_tooltip_method`] so
/// the ask-once discipline stays in one place): a `SetTalent` render whose next-rank view is
/// missing queues it exactly like a primary-view miss.
pub(super) fn ask_next_rank(lua: &Lua, next_spell: u32) {
if next_spell != 0 {
let _ = spell_view_of(lua, next_spell); // a miss records the ask as a side effect
}
}