Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)
Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one. - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`. - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale. - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent. - A StatusBar fills from its internal normalised fraction, not from `GetValue()`. - The addresses of `StartDuel` and `StartDuelUnit` were swapped. - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8505187668
commit
fd386e75ae
@@ -1,5 +1,4 @@
|
||||
//! The Lua globals (`GetTradeSkillInfo`, `DoTradeSkill`, the filter family, …) — [`install`] wires
|
||||
//! the Era `TradeSkillFrame.lua` API surface onto [`super::view`]'s display-tree/filter machinery.
|
||||
//! The tradeskill Lua globals over [`super::view`]'s visible rows and filters.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
@@ -20,7 +19,7 @@ fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A `bool` as the Era `1`/`nil` shape.
|
||||
/// A `bool` as the 1.12 API's `1` or `nil`.
|
||||
fn era_bool(b: bool) -> Value {
|
||||
if b {
|
||||
Value::Integer(1)
|
||||
@@ -33,8 +32,7 @@ fn era_bool(b: bool) -> Value {
|
||||
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetTradeSkillLine() → lineName, rank, maxRank; ("UNKNOWN", 0, 0) with no window open (the
|
||||
// ref's own no-tradeskill shape).
|
||||
// GetTradeSkillLine(): ("UNKNOWN", 0, 0) with no window open, as the reference answers.
|
||||
g.set(
|
||||
"GetTradeSkillLine",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -51,8 +49,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// → the number of VISIBLE rows the open window offers — headers + the recipes of uncollapsed
|
||||
// groups (0 when closed).
|
||||
// GetNumTradeSkills(): the visible rows, headers included.
|
||||
g.set(
|
||||
"GetNumTradeSkills",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -61,12 +58,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The two link verbs (decision 1973).
|
||||
//
|
||||
// GetTradeSkillItemLink(index) — `0x4ff410`: the number gate raises its Usage; then ZERO
|
||||
// values on every miss — an index off the list, a header row, a recipe with no product, an
|
||||
// uncached product template (no query is ever sent; the app pre-asks when the list lands) —
|
||||
// and otherwise ONE string, the product's `|Hitem:` link in its quality colour.
|
||||
// GetTradeSkillItemLink(index) (`0x4ff410`): zero values on any miss (a header, no product, an
|
||||
// uncached template, which is never queried here), else the product's item link.
|
||||
g.set(
|
||||
"GetTradeSkillItemLink",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -94,10 +87,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillReagentItemLink(index, reagentIndex) — `0x4ff800`: both arguments through the
|
||||
// number gate, raising `Usage: GetTradeReagentSkillItemLink(…)` — Blizzard's own typo, kept.
|
||||
// `reagentIndex` is 1-based over the NON-EMPTY reagent slots and never range-checked in the
|
||||
// client; ALWAYS exactly one value: the reagent's link, or nil on any miss.
|
||||
// GetTradeSkillReagentItemLink(index, reagentIndex) (`0x4ff800`): always one value, the link
|
||||
// or nil; `reagentIndex` counts the non-empty reagent slots, and the usage error's
|
||||
// `GetTradeReagentSkillItemLink` is the reference's own typo.
|
||||
g.set(
|
||||
"GetTradeSkillReagentItemLink",
|
||||
lua.create_function(|lua, (index, reagent): (Value, Value)| {
|
||||
@@ -128,10 +120,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillInfo(index) → name, type, numAvailable, isExpanded. `index` 1-based into the
|
||||
// VISIBLE row list (the module doc's grouped-list law, `0x4fca20`). A header row:
|
||||
// (groupName, "header", 0, isExpanded 1/nil). A recipe row: (name, difficulty color key,
|
||||
// numAvailable, nil). Out of range → a single nil.
|
||||
// GetTradeSkillInfo(index): (name, "header", 0, isExpanded) for a header, (name, difficulty,
|
||||
// numAvailable, nil) for a recipe, one nil out of range.
|
||||
g.set(
|
||||
"GetTradeSkillInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -169,7 +159,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetFirstTradeSkill() → the first NON-header visible index (0 when none).
|
||||
g.set(
|
||||
"GetFirstTradeSkill",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -178,9 +167,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillSubClasses() → the current group names, in group order (`0x4ffb60`: the
|
||||
// filter-dropdown vocabulary IS the header list) — though v1 ships no
|
||||
// filter dropdown to consume it yet (the SubClass/InvSlot filter family below stays inert).
|
||||
// GetTradeSkillSubClasses(): the group names in order, unfiltered (`0x4ffb60`).
|
||||
g.set(
|
||||
"GetTradeSkillSubClasses",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -195,8 +182,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ExpandTradeSkillSubClass(i) / CollapseTradeSkillSubClass(i) — fold a group by its header's
|
||||
// VISIBLE index (i == 0 = ALL groups, the CollapseAll semantics — see [`set_collapsed`]).
|
||||
// Expand/CollapseTradeSkillSubClass(i): the group whose header is visible row i; 0 is all.
|
||||
g.set(
|
||||
"ExpandTradeSkillSubClass",
|
||||
lua.create_function(|lua, id: usize| {
|
||||
@@ -216,7 +202,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillIcon(index) → icon texture path (nil while in flight / OOB / a header row).
|
||||
g.set(
|
||||
"GetTradeSkillIcon",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -225,7 +210,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillNumMade(index) → minMade, maxMade (0, 0 when OOB / no window / a header row).
|
||||
g.set(
|
||||
"GetTradeSkillNumMade",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -239,8 +223,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillCooldown(index) → remaining cooldown seconds, or nil when ready / OOB / a header
|
||||
// row (the ref Lua tests this return for truthiness).
|
||||
// GetTradeSkillCooldown(index): nil when ready, which the stock Lua tests for truthiness
|
||||
// (Blizzard_TradeSkillUI.lua:215).
|
||||
g.set(
|
||||
"GetTradeSkillCooldown",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -254,8 +238,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillNumReagents(index) → this recipe's reagent count (0 when OOB / no window / a
|
||||
// header row).
|
||||
g.set(
|
||||
"GetTradeSkillNumReagents",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -264,10 +246,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillReagentInfo(index, reagentIndex) → name, icon, need, have (a single nil when the
|
||||
// recipe/reagent index is OOB or `index` is a header row). name/icon are themselves nil while
|
||||
// the ask-once template answer is in flight — the ref grays/counts the row off exactly these
|
||||
// four.
|
||||
// GetTradeSkillReagentInfo(index, reagentIndex): one nil on a miss; name and icon are nil
|
||||
// until the item template arrives.
|
||||
g.set(
|
||||
"GetTradeSkillReagentInfo",
|
||||
lua.create_function(|lua, (index, reagent_index): (usize, usize)| {
|
||||
@@ -286,9 +266,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeSkillTools(index) → an alternating (name, has) multivalue, one pair per Requirements
|
||||
// tool (e.g. "Anvil", 1, "Mining Pick", nil) — the ref feeds this straight into
|
||||
// BuildColoredListString. Empty when the recipe has no tools / index is OOB / a header row.
|
||||
// GetTradeSkillTools(index): alternating (name, has) pairs, which the stock Lua feeds to
|
||||
// `BuildColoredListString` (Blizzard_TradeSkillUI.lua:274).
|
||||
g.set(
|
||||
"GetTradeSkillTools",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -304,8 +283,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTradeskillRepeatCount() → the remaining Create All repeats (note the lowercase "s" in
|
||||
// "Tradeskill" — that IS the real 1.12 API name). 0 with no window open.
|
||||
// GetTradeskillRepeatCount(): the lowercase "s" is the 1.12 name.
|
||||
g.set(
|
||||
"GetTradeskillRepeatCount",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -316,9 +294,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SelectTradeSkill(index) / GetTradeSkillSelectionIndex() — the engine-held selection, VISIBLE
|
||||
// index in ([`select`]: a header index is IGNORED, not cleared), VISIBLE index out
|
||||
// ([`selected_visible_index`]), held internally as a stable flat-recipe position (module doc).
|
||||
// SelectTradeSkill and GetTradeSkillSelectionIndex take and answer visible indices.
|
||||
g.set(
|
||||
"SelectTradeSkill",
|
||||
lua.create_function(|lua, index: u32| {
|
||||
@@ -335,9 +311,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// DoTradeSkill(index, count) — queue the recipe's SPELL ID for `count` crafts (default 1, never
|
||||
// less than 1) — the app's repeat machine turns this into that many CMSG_CAST_SPELL sends. Out
|
||||
// of range / a header index → ignored.
|
||||
// DoTradeSkill(index, count): queues the recipe's spell for `count` crafts, which the app sends
|
||||
// as one `CMSG_CAST_SPELL` each.
|
||||
g.set(
|
||||
"DoTradeSkill",
|
||||
lua.create_function(|lua, (index, count): (usize, Option<i64>)| {
|
||||
@@ -345,8 +320,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
if let Some((spell_id, avail)) =
|
||||
recipe_at(&model, index).map(|r| (r.spell_id, r.num_available))
|
||||
{
|
||||
// The client clamps the repeat to numAvailable at the latch
|
||||
// (`DoTradeSkill 0x500280`).
|
||||
// The client clamps the repeat to numAvailable, at least 1 (`0x500280`).
|
||||
let n = (count.unwrap_or(1).max(1) as u32).min(avail.max(1));
|
||||
model.trade_skill_dos.push((spell_id, n));
|
||||
}
|
||||
@@ -354,8 +328,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CloseTradeSkill() — client-side close (no packet, vanilla): flag it so the app clears its
|
||||
// local state.
|
||||
// CloseTradeSkill(): client-side only, no packet; the app clears its state.
|
||||
g.set(
|
||||
"CloseTradeSkill",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -365,16 +338,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The sub-class/inv-slot FILTER family — real now (the dropdowns ship, ref
|
||||
// Blizzard_TradeSkillUI.lua l.314-414). Indexing follows the C originals: the SubClass index
|
||||
// is 1-based into the CURRENT GetTradeSkillSubClasses order (`0x4ffc70` bounds-checks against
|
||||
// the live header count), the InvSlot index 1-based into the GetTradeSkillInvSlots order;
|
||||
// index 0 is the "All" pseudo-entry on both. `Set*(0, 1, …)` = everything back on (the ref's
|
||||
// "All Subclasses"/"All Slots" menu row); `Set*(i, 1, 1)` = EXCLUSIVE — only entry i shown
|
||||
// (every ref menu click passes exactly this shape); `Set*(i, 1)` un-hides i; `Set*(i, 0)`
|
||||
// hides it. Each set re-lists + fires TRADE_SKILL_UPDATE via the touched flag (the `0x4fd710`
|
||||
// re-sort + event 0x139 shape). `Get*(0)` answers "is everything shown" — the ref's
|
||||
// all-checked probe.
|
||||
// The subclass and inv-slot filters (Blizzard_TradeSkillUI.lua:314-414): index i is 1-based
|
||||
// into `GetTradeSkillSubClasses` (`0x4ffc70` bounds-checks it) or `GetTradeSkillInvSlots`, and
|
||||
// 0 is all. `Set*(i, 1, 1)` shows only i, as every menu click does; `Set*(i, 1)` shows i and
|
||||
// `Set*(i, 0)` hides it. Each set fires TRADE_SKILL_UPDATE through the touched flag
|
||||
// (`0x4fd710`), and `Get*(0)` asks whether everything is shown. The reference's per-header
|
||||
// subclass flag (`+0xc`, mask `0x84dd60`) is kept here as hidden group keys.
|
||||
g.set(
|
||||
"GetTradeSkillSubClassFilter",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -432,17 +401,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
// GetTradeSkillInvSlots() → the distinct slot words of the open window's products, ascending
|
||||
// slot-bit order (the `0xbde058` bit walk — [`present_inv_slots`]).
|
||||
// GetTradeSkillInvSlots(): the products' slot names in ascending bit order (`0xbde058`).
|
||||
g.set(
|
||||
"GetTradeSkillInvSlots",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let mut out = Vec::new();
|
||||
// Each word is its `0x84dd70` token resolved off the player's own string table
|
||||
// (decision 2045). A token the table does not carry answers the empty string rather
|
||||
// than being dropped: the list is POSITIONAL — `GetTradeSkillInvSlotFilter(index)`
|
||||
// indexes the same order — so a hole would shift every filter after it.
|
||||
// A token (`0x84dd70`) missing from the string table answers "" rather than being
|
||||
// dropped: the filter verbs index this list by position.
|
||||
for bit in present_inv_slots(&model) {
|
||||
let word = inv_slot_token(bit)
|
||||
.and_then(|k| crate::strings::global(lua, k))
|
||||
@@ -452,9 +418,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(MultiValue::from_vec(out))
|
||||
})?,
|
||||
)?;
|
||||
// The InvSlot pair reads/writes the shown-mask (`0x84dd64`) with the index resolving to the
|
||||
// (index-1)-th SET bit of the accumulated present-slots mask — present slots only, ascending
|
||||
// (`0x4ffe60`'s enumeration; `GetTradeSkillInvSlots` returns exactly that order).
|
||||
// The inv-slot pair works on the shown mask (`0x84dd64`); index i is the i-th present slot bit
|
||||
// (`0x4ffe60`).
|
||||
g.set(
|
||||
"GetTradeSkillInvSlotFilter",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -464,7 +429,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
}
|
||||
let bits = present_inv_slots(&model);
|
||||
Ok(match index.checked_sub(1) {
|
||||
// The "all shown?" probe: (present & mask) == present (`0x4fffd0`).
|
||||
// Everything shown: (present & mask) == present (`0x4fffd0`).
|
||||
None => era_bool(
|
||||
bits.iter()
|
||||
.all(|&b| model.trade_skill_invslot_mask & (1 << b) != 0),
|
||||
@@ -487,8 +452,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let bits = present_inv_slots(&model);
|
||||
let on = on.unwrap_or(1) != 0;
|
||||
let exclusive = exclusive.unwrap_or(0) != 0;
|
||||
// The exact mask math per path (the recovered `0x4fd730` args): all →
|
||||
// 0xffffffff · off → old & ~(1<<b) · exclusive → 1<<b · add → old | (1<<b).
|
||||
// The mask each path writes, as `0x4fd730` is called.
|
||||
match index.checked_sub(1) {
|
||||
None => model.trade_skill_invslot_mask = u32::MAX,
|
||||
Some(n) => {
|
||||
|
||||
@@ -1,90 +1,8 @@
|
||||
//! The tradeskill bindings (decision 0437 phase 2) — the Era-shaped crafting-window surface driving a
|
||||
//! faithful port of the real 1.12 `TradeSkillFrame` (extracted from `interface.MPQ`:
|
||||
//! `Interface\FrameXML\Blizzard_TradeSkillUI\TradeSkillFrame.{xml,lua}`). Same two-way seam as
|
||||
//! [`super::trainer`]: the app pushes a **tradeskill snapshot** ([`UiScript::set_trade_skill`] — the
|
||||
//! recipes already resolved to name/icon/reagents/tools/color by the app from `Spell.dbc`/
|
||||
//! `SkillLineAbility.dbc`/bag counts), and the Lua `DoTradeSkill`/`CloseTradeSkill` calls queue
|
||||
//! outbound **intents** the app drains ([`UiScript::take_trade_skill_dos`] returns
|
||||
//! `(spell id, count)` pairs / [`UiScript::take_trade_skill_close`]). The engine holds no recipe
|
||||
//! knowledge — a recipe is name/icon/difficulty/reagents/tools/cooldown, all app-resolved (0437 §"The
|
||||
//! decision" item 1).
|
||||
//!
|
||||
//! ## The Era API shape (matched to the real `TradeSkillFrame.lua`)
|
||||
//!
|
||||
//! `GetTradeSkillInfo(index) → name, type, numAvailable, isExpanded`: `type` is either the recipe's
|
||||
//! difficulty color key (`"optimal"|"medium"|"easy"|"trivial"`, [`TradeSkillDifficulty::as_str`]) or
|
||||
//! `"header"` for a group row (see below); `isExpanded` is `nil` for a recipe row, `1`/`nil` for a
|
||||
//! header. `GetTradeSkillReagentInfo`/`GetTradeSkillTools`/`GetTradeSkillCooldown`/
|
||||
//! `GetTradeSkillNumMade`/`GetTradeskillRepeatCount` (note the lowercase "s" in "Tradeskill" — that
|
||||
//! IS the real 1.12 spelling) round out the detail pane; `DoTradeSkill` queues the craft,
|
||||
//! `CloseTradeSkill` is the client-side close (vanilla sends no packet here either, matching
|
||||
//! [`super::trainer`]'s `CloseTrainer` precedent).
|
||||
//!
|
||||
//! ## The grouped list (`0x4fca20`)
|
||||
//!
|
||||
//! Unlike v1's flat render, the engine now owns the WHOLE display tree, built fresh from
|
||||
//! [`TradeSkillState::recipes`] on every query ([`build_groups`]) — [`TradeSkillState::recipes`] is
|
||||
//! just the app's flat, unordered input; its push order carries no meaning. Every recipe belongs to
|
||||
//! a group keyed by the CREATED item's `(ItemClass, ItemSubClass)`, named from `ItemSubClass.dbc`
|
||||
//! (resolved app-side — [`TradeSkillRecipe::group`]); a recipe whose product template is still in
|
||||
//! flight has no group yet (the client's own one-frame header deferral) and buckets trailing under
|
||||
//! a synthesized group keyed `(u32::MAX, u32::MAX)` with an empty name — no real `ItemClass` reaches
|
||||
//! `u32::MAX`, so it sorts last with no special-casing. Groups order by `ItemClass` id ascending,
|
||||
//! ties broken alphabetically by group name (case-insensitive, never by subclass id); within a
|
||||
//! group, recipes order by difficulty tier ascending (Optimal < Medium < Easy < Trivial — the byte
|
||||
//! tiers 0..3, [`TradeSkillDifficulty::tier`]), ties broken alphabetically by recipe name. The
|
||||
//! client's own middle tiebreak (an item-template field) is SKIPPED — the one INFERRED remainder,
|
||||
//! named and pinned like [`TradeSkillDifficulty`]'s own INTERIM color law once was.
|
||||
//!
|
||||
//! Visible rows are each group's header (always shown) plus — for an uncollapsed group — its
|
||||
//! recipes; every index the Era API takes/returns is 1-based into THAT list (mirrors
|
||||
//! [`super::skills`]'s tree exactly: groups + collapse + visible-index remapping). `GetTradeSkillInfo`
|
||||
//! on a header row returns `(groupName, "header", 0, isExpanded)`; `GetNumTradeSkills()` counts
|
||||
//! visible rows; `GetFirstTradeSkill()` is the first NON-header visible index (`0` when none).
|
||||
//! `Expand/CollapseTradeSkillSubClass(i)` fold a group by its header's VISIBLE index (`i == 0` =
|
||||
//! ALL groups — the CollapseAll button's own call shape); the collapse set is keyed by group key
|
||||
//! ([`Model::trade_skill_collapsed`]) and survives a `set_trade_skill` content re-push (pruned to
|
||||
//! still-live groups). `GetTradeSkillSubClasses()` returns the current group names in group
|
||||
//! order — the VERIFIED filter-dropdown vocabulary IS the header list, and it never shrinks
|
||||
//! under a filter — and the `SubClassFilter`/`InvSlot` filter family: the subclass filter is
|
||||
//! hidden group keys (the client's per-header
|
||||
//! `+0xc` flag, position mask `0x84dd60` derived), the inv-slot filter is a shown-bit mask
|
||||
//! (`0x84dd64`) over the accumulated slot vocabulary, [`rows`] drops filtered recipes and any
|
||||
//! group left empty (a merely-collapsed group keeps its header), and every engine-side mutator
|
||||
//! (a filter set, an expand/collapse) raises the `trade_skill_touched` flag the app answers with
|
||||
//! `TRADE_SKILL_UPDATE` — the real client's in-call recompute+resort + event 0x13a (the
|
||||
//! `0x4fd710`/`0x4fd730`/`0x4fd750` writer trio). Persistence is the client's `0xbde064`-keyed
|
||||
//! story: filters, collapse, and the spell-id selection all survive a same-profession
|
||||
//! close→reopen and reset on a profession switch ([`UiScript::set_trade_skill`]). The InvSlot
|
||||
//! vocabulary folds each product's `InventoryType` through the dumped `DAT_00809200` table
|
||||
//! ([`inv_slot_mask`] — WEAPON contributes both hand bits) and names bits via the `0x84dd70`
|
||||
//! token table ([`inv_slot_name`]).
|
||||
//!
|
||||
//! Every per-recipe getter — `DoTradeSkill`, `GetTradeSkillIcon`/`NumMade`/`Cooldown`/`NumReagents`/
|
||||
//! `ReagentInfo`/`Tools` — resolves its `index` through this same visible mapping via [`recipe_at`]
|
||||
//! and no-ops/nil-shapes on a header row. **`SelectTradeSkill(i)` on a header index is IGNORED, not
|
||||
//! cleared** — the ref's own `SetSelection` toggles the fold instead of selecting (ref l.184-192,
|
||||
//! transcribed in the XML); the engine simply leaves the prior selection untouched. The selection
|
||||
//! itself is held internally as a position into the OPEN WINDOW's FLAT `recipes` list — stable
|
||||
//! across a collapse/expand (which never reorders `recipes`, only what's visible) and remapped by
|
||||
//! spell id only across a `set_trade_skill` re-push ([`selected_visible_index`] derives the current
|
||||
//! VISIBLE index from that flat position on every read, [`super::skills`]'s own by-identity
|
||||
//! persistence pattern adapted to a field this module can't change the type of).
|
||||
//!
|
||||
//! ## The tooltip channel
|
||||
//!
|
||||
//! `GameTooltip:SetTradeSkillItem(skillIndex [, reagentIndex])` is registered in
|
||||
//! [`super::tooltip_item`], beside `SetMerchantItem`, through the same id-keyed item renderer: with
|
||||
//! `reagentIndex` it resolves that recipe's reagent's item, without it the recipe's PRODUCT item. A
|
||||
//! product id of `0` (a pure-effect recipe) or an in-flight ask-once template answer both fall back to
|
||||
//! the renderer's own name-only line — the recipe's name for the product channel — rather than a
|
||||
//! no-op, since that fallback already exists and reads better than a blank hover.
|
||||
//!
|
||||
//! `skillIndex` is a **VISIBLE** index and is resolved through [`recipe_at`] (`pub(crate)` for
|
||||
//! exactly this), never a raw [`TradeSkillState::recipes`] position — headers interleave with rows
|
||||
//! since the grouped build (`0x4fca20`) landed, so a raw index would show the wrong item the moment
|
||||
//! any group precedes the selected recipe's own. A header index resolves to `None` and the hover is
|
||||
//! a no-op.
|
||||
//! The tradeskill bindings behind the stock `Blizzard_TradeSkillUI`: the app pushes a snapshot
|
||||
//! ([`UiScript::set_trade_skill`]) with recipes resolved from `Spell.dbc`, `SkillLineAbility.dbc`
|
||||
//! and bag counts, and drains the intents `DoTradeSkill` and `CloseTradeSkill` queue. The engine
|
||||
//! owns the grouped, filtered list (`0x4fca20`); every Lua index, `SetTradeSkillItem`'s included,
|
||||
//! is 1-based into its visible rows ([`recipe_at`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -99,27 +17,23 @@ use view::build_groups;
|
||||
pub(super) use api::install;
|
||||
pub(crate) use view::recipe_at;
|
||||
|
||||
/// The recipe difficulty band (the color law, computed app-side): the Lua color-table key
|
||||
/// `GetTradeSkillInfo` returns as its `type`. These four names ARE the real client's own
|
||||
/// `TradeSkillTypeColor` keys (`TradeSkillFrame.lua`); WHICH recipes land in which band (the
|
||||
/// trivial-rank cut points) is CONFIRMED at the bytes (decision 0446, `0x4fca20`:
|
||||
/// gray ≥ trivialHigh, green ≥ the low/high midpoint, yellow ≥ trivialLow, orange below) — this
|
||||
/// enum is the verified Era vocabulary the app's law picks from.
|
||||
/// A recipe's difficulty band, computed app-side: gray at or above trivialHigh, green from the
|
||||
/// low/high midpoint, yellow from trivialLow, orange below (`0x4fca20`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TradeSkillDifficulty {
|
||||
/// Orange — a near-certain skill-up (the hardest recipes relative to current rank).
|
||||
/// Orange: a near-certain skill-up.
|
||||
Optimal,
|
||||
/// Yellow — a good skill-up chance.
|
||||
/// Yellow: a good skill-up chance.
|
||||
Medium,
|
||||
/// Green — a fading skill-up chance.
|
||||
/// Green: a fading skill-up chance.
|
||||
Easy,
|
||||
/// Gray — no more skill-up; still craftable for its own sake.
|
||||
/// Gray: no skill-up.
|
||||
Trivial,
|
||||
}
|
||||
|
||||
impl TradeSkillDifficulty {
|
||||
/// The Era `type` string `GetTradeSkillInfo` returns for a RECIPE row (a header row returns
|
||||
/// `"header"` instead — the module doc's grouped list).
|
||||
/// A recipe row's `type` in `GetTradeSkillInfo`, a `TradeSkillTypeColor` key
|
||||
/// (`Blizzard_TradeSkillUI.lua:7-10`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TradeSkillDifficulty::Optimal => "optimal",
|
||||
@@ -129,12 +43,8 @@ impl TradeSkillDifficulty {
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte tier a group sorts recipes by, ascending (`0x4fd380`):
|
||||
/// Optimal < Medium < Easy < Trivial, 0..3. The **Craft** window's row comparators key on the
|
||||
/// same tier byte first (`row[+0xc]`, decision 1124), which is why this is `pub(crate)` and not
|
||||
/// private to this module: the client's two code tables are one vocabulary shifted by one
|
||||
/// (Craft's `0` is a `"none"` tier benilla cannot produce), so the *relative* order is identical
|
||||
/// and the shift is invisible to a comparator.
|
||||
/// The tier byte recipes sort by, ascending (`0x4fd380`). The Craft window's comparators key on
|
||||
/// the same order (`row[+0xc]`, shifted by one for a "none" tier), so it shares this.
|
||||
pub(crate) fn tier(self) -> u8 {
|
||||
match self {
|
||||
TradeSkillDifficulty::Optimal => 0,
|
||||
@@ -145,114 +55,74 @@ impl TradeSkillDifficulty {
|
||||
}
|
||||
}
|
||||
|
||||
/// One reagent a recipe consumes (`GetTradeSkillReagentInfo`): the bag-counted need/have plus the
|
||||
/// resolved name/icon — both `None` until the app's ask-once item-template answer lands (the same
|
||||
/// miss path as [`super::ItemTemplateView`]'s own asks), at which point the ref Lua sees the slot as
|
||||
/// blank (a `nil` name/texture hides the row's icon+text, not an error state).
|
||||
/// One reagent of a recipe, as `GetTradeSkillReagentInfo` reports it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TradeSkillReagent {
|
||||
/// The reagent's item entry — the tooltip channel key (`GameTooltip:SetTradeSkillItem(i, j)`
|
||||
/// resolves through this, never through `name`/`icon`).
|
||||
/// The item entry `SetTradeSkillItem(i, j)` resolves through.
|
||||
pub item: u32,
|
||||
/// The resolved display name, or `None` while the ask-once template answer is still in flight.
|
||||
/// `None` until the item template arrives.
|
||||
pub name: Option<String>,
|
||||
/// The resolved icon texture path, or `None` while still in flight.
|
||||
/// `None` until the item template arrives.
|
||||
pub icon: Option<String>,
|
||||
/// How many this recipe consumes.
|
||||
pub need: u32,
|
||||
/// How many the player's bags currently hold (`count_of`, decision 0269) — the row's own
|
||||
/// have-vs-need gate, which the ref Lua grays/counts off directly.
|
||||
/// How many the player's bags hold.
|
||||
pub have: u32,
|
||||
}
|
||||
|
||||
/// One recipe row (`GetTradeSkillInfo` + its detail-pane getters): a known spell carrying attr
|
||||
/// `SPELL_ATTR_IS_TRADESKILL` (0437 §"What scoping verified"), resolved by the app from
|
||||
/// `Spell.dbc`/`SkillLineAbility.dbc`/bag counts into everything the window shows.
|
||||
/// One recipe: a known spell with `SPELL_ATTR_IS_TRADESKILL`, resolved app-side from `Spell.dbc`,
|
||||
/// `SkillLineAbility.dbc` and bag counts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TradeSkillRecipe {
|
||||
/// The header key (`0x55ba30`): the created item's `(ItemClass,
|
||||
/// ItemSubClass)` + the resolved `ItemSubClass.dbc` display name; `None` while the product's
|
||||
/// template is still in flight (the client's own one-frame header deferral) — [`build_groups`]
|
||||
/// buckets those trailing under a synthesized empty-named group.
|
||||
/// The product's `(ItemClass, ItemSubClass)` and `ItemSubClass.dbc` name, the header key
|
||||
/// (`0x55ba30`); `None` until the product's template arrives.
|
||||
pub group: Option<(u32, u32, String)>,
|
||||
/// The recipe spell id — what `DoTradeSkill` queues as the `CMSG_CAST_SPELL` target (one send
|
||||
/// per item crafted, decision 0437 — there is no wire count field).
|
||||
/// What `DoTradeSkill` casts, one `CMSG_CAST_SPELL` per item: the packet has no count.
|
||||
pub spell_id: u32,
|
||||
pub name: String,
|
||||
/// The color band `GetTradeSkillInfo`'s `type` reports ([`TradeSkillDifficulty`]).
|
||||
pub difficulty: TradeSkillDifficulty,
|
||||
/// `min` over reagents of `floor(have/need)` — `0` when any reagent is short (uncraftable); the
|
||||
/// count `GetTradeSkillInfo` reports as `numAvailable`.
|
||||
/// The least `have / need` over the reagents, reported as `numAvailable`.
|
||||
pub num_available: u32,
|
||||
/// The product item's icon if it resolved, else the spell's own icon (an app-resolved fallback);
|
||||
/// `None` only while both are still in flight.
|
||||
/// The product's icon, else the spell's; `None` while neither has arrived.
|
||||
pub icon: Option<String>,
|
||||
pub min_made: u32,
|
||||
pub max_made: u32,
|
||||
/// Remaining tradeskill cooldown in seconds (the Mooncloth-style per-recipe cooldown); `None` =
|
||||
/// ready now (`GetTradeSkillCooldown` returns `nil`, which the ref Lua tests for truthiness).
|
||||
/// Remaining cooldown in seconds; `None` when ready.
|
||||
pub cooldown_secs: Option<u64>,
|
||||
/// The item entry this recipe creates — the tooltip channel key for the no-`reagentIndex` call to
|
||||
/// `SetTradeSkillItem`; `0` = no product item (a pure-effect recipe has nothing to show there).
|
||||
/// The created item, 0 for a pure-effect recipe.
|
||||
pub product_item: u32,
|
||||
/// The product's `InventoryType` (item template) — the InvSlot filter's raw input, folded to
|
||||
/// its slot-bit contribution by [`inv_slot_mask`] (the real client's
|
||||
/// `DAT_00809200[InventoryType]` OR-accumulate (`0x4fcee6`) + overrides).
|
||||
/// `0` (non-equip, or the template still in flight) folds to the `0x800000` catch-all bit.
|
||||
/// The product's `InventoryType`, which the inv-slot filter folds to slot bits through
|
||||
/// `DAT_00809200` (`0x4fcee6`); 0 takes the catch-all bit.
|
||||
pub product_inv_type: u32,
|
||||
/// The product's `ItemLevel` (item template `+0x38`) — the sort's SECONDARY key, ascending,
|
||||
/// between the difficulty tier and the name (the `record+0x14` field identity: the dbcache
|
||||
/// `ItemStats_C::Read 0x7c9640` write order). `0` while the template is in flight — moot,
|
||||
/// since a template-less recipe has
|
||||
/// no group yet and buckets trailing anyway.
|
||||
/// The product's `ItemLevel`, the sort key between tier and name (`record+0x14`, `0x7c9640`).
|
||||
pub product_item_level: u32,
|
||||
/// Consumed reagents, in display order (`GetTradeSkillNumReagents`/`GetTradeSkillReagentInfo`).
|
||||
/// In display order.
|
||||
pub reagents: Vec<TradeSkillReagent>,
|
||||
/// The Requirements line's tool list — `(tool name, have)` pairs (e.g. `("Anvil", true)`); exposed
|
||||
/// to Lua as `GetTradeSkillTools`'s alternating multivalue, which the ref feeds into
|
||||
/// `BuildColoredListString`.
|
||||
/// The Requirements line's `(tool name, have)` pairs.
|
||||
pub tools: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
/// One open tradeskill window: the skill line's name/rank and its recipe list. Pushed whole by the
|
||||
/// app ([`UiScript::set_trade_skill`]); `None` means the window is closed.
|
||||
/// One open tradeskill window, pushed whole by the app.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TradeSkillState {
|
||||
/// The skill line id (`SkillLine.dbc`) — the persistence cache key (`0xbde064`): a push
|
||||
/// for a different line than the last resets the filters, the
|
||||
/// collapse set, and the selection; the same line keeps them, across close/reopen too.
|
||||
/// The `SkillLine.dbc` id, the persistence key (`0xbde064`).
|
||||
pub line: u32,
|
||||
/// The skill line's display name (`SkillLine.dbc`) — e.g. `"Tailoring"`.
|
||||
pub line_name: String,
|
||||
pub rank: u32,
|
||||
pub max_rank: u32,
|
||||
/// The window's recipe rows — the app's FLAT, UNORDERED input; the engine owns ALL ordering
|
||||
/// (group + tier + name, the module doc's grouped-list law, `0x4fca20`) and
|
||||
/// builds the display tree fresh from this on every query ([`build_groups`]), so push order
|
||||
/// carries no meaning. The Era API's `index` is 1-based into the synthesized VISIBLE list, never
|
||||
/// straight into this slice — see [`recipe_at`].
|
||||
/// Unordered: the engine sorts them (`0x4fca20`), and Lua indexes the visible rows, not this.
|
||||
pub recipes: Vec<TradeSkillRecipe>,
|
||||
/// Remaining Create All repeats (`GetTradeskillRepeatCount`) — the client-side repeat machine's
|
||||
/// own counter (`0x500230`, decision 0446), not engine-driven: the app decrements
|
||||
/// and re-pushes as each repeat's cast resolves.
|
||||
/// Remaining Create All repeats (`0x500230`), decremented by the app as each cast resolves.
|
||||
pub repeat_count: u32,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push (or clear, with `None`) the open tradeskill window's recipe snapshot. Snapshots re-push
|
||||
/// every time a resolved field changes (reagent counts, ask-once answers landing, …) — no
|
||||
/// diffing happens here.
|
||||
/// Pushes the open window's snapshot, or `None` on close; the app re-pushes on any change.
|
||||
///
|
||||
/// Persistence is the real client's (`0x4fc910`, keyed by the
|
||||
/// `0xbde064` last-built-line cache): a push for a **different skill line** resets the two
|
||||
/// filters, the collapse set, and the selection; the **same line** keeps all of them — and
|
||||
/// because a close (`None`) touches none of that state either, they survive a close→reopen
|
||||
/// round trip exactly like the client's statics do. The selection rides its **spell id**
|
||||
/// (`0xbde044`'s own storage) — on every push it remaps to the id's new flat position, or
|
||||
/// clears if the recipe vanished. The collapse/subclass-hidden key sets are pruned to the
|
||||
/// groups the fresh recipes still produce (the client's save→restore-by-key round trip loses
|
||||
/// exactly the keys that stopped existing); the inv-slot mask is deliberately NOT pruned —
|
||||
/// the build never touches `0x84dd64`.
|
||||
/// Persistence follows the reference (`0x4fc910`, keyed by `0xbde064`): a different skill line
|
||||
/// resets filters, folds and selection, and the same line keeps them across a close. The
|
||||
/// selection rides its spell id (`0xbde044`); fold and subclass keys are pruned to live groups,
|
||||
/// while the inv-slot mask (`0x84dd64`) is never pruned.
|
||||
pub fn set_trade_skill(&mut self, state: Option<TradeSkillState>) {
|
||||
let mut model = self.model_mut();
|
||||
match state {
|
||||
@@ -289,28 +159,21 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the **(spell id, count)** intents `DoTradeSkill` queued since the last call — the engine
|
||||
/// resolves each clicked recipe's INDEX to its spell id, so the app sends `CMSG_CAST_SPELL`
|
||||
/// without needing the index mapping; `count` is the total the app's own client-side repeat
|
||||
/// loop (decision 0437 §5, `0x4fd7b0`) turns into that many sequential sends, one per item —
|
||||
/// "Create" queues `1`, "Create All" queues [`TradeSkillState::repeat_count`].
|
||||
/// Drains the `(spell id, count)` crafts `DoTradeSkill` queued; the app's repeat loop
|
||||
/// (`0x4fd7b0`) sends one cast per item.
|
||||
pub fn take_trade_skill_dos(&mut self) -> Vec<(u32, u32)> {
|
||||
std::mem::take(&mut self.model_mut().trade_skill_dos)
|
||||
}
|
||||
|
||||
/// Whether `CloseTradeSkill` was called since the last drain (and clear the flag). Like
|
||||
/// [`super::trainer`]'s `CloseTrainer`, the client-side close sends no packet — the app just
|
||||
/// clears its local tradeskill state.
|
||||
/// Whether `CloseTradeSkill` ran since the last drain; the close sends no packet.
|
||||
pub fn take_trade_skill_close(&mut self) -> bool {
|
||||
std::mem::take(&mut self.model_mut().trade_skill_close)
|
||||
}
|
||||
|
||||
/// Whether an engine-side list mutator ran since the last drain (a filter set, an
|
||||
/// expand/collapse) — the real client answers those from inside the C call with a
|
||||
/// recompute+resort + `TRADE_SKILL_UPDATE` (the `0x4fd710`/`0x4fd730`/`0x4fd750` writer trio
|
||||
/// → event 0x13a); the app drains this and fires the event the
|
||||
/// same frame (the ref's `CollapseAllButton_OnClick` and the filter-menu clicks repaint ONLY
|
||||
/// off that event — no direct `TradeSkillFrame_Update()` call).
|
||||
/// Whether a filter or fold changed since the last drain. The reference re-sorts and fires
|
||||
/// `TRADE_SKILL_UPDATE` (event `0x13a`) inside the call (`0x4fd710`, `0x4fd730`, `0x4fd750`);
|
||||
/// the app fires it the same frame, and the stock filter and collapse-all clicks repaint only
|
||||
/// off that event.
|
||||
pub fn take_trade_skill_touched(&mut self) -> bool {
|
||||
std::mem::take(&mut self.model_mut().trade_skill_touched)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// One recipe fixture — a single-reagent, single-tool row, distinct spell/product ids per name.
|
||||
/// A single-reagent, single-tool recipe fixture.
|
||||
fn recipe(
|
||||
spell_id: u32,
|
||||
name: &str,
|
||||
@@ -20,9 +20,8 @@ fn recipe(
|
||||
max_made: 1,
|
||||
cooldown_secs: None,
|
||||
product_item: spell_id + 10_000,
|
||||
product_inv_type: 20, // Robe — folds to the Chest slot bit (4)
|
||||
// Neutral by default (0 everywhere = the tie falls through to the name, the pre-verdict
|
||||
// order every other test pins); the ItemLevel tie-break test overrides it explicitly.
|
||||
product_inv_type: 20, // Robe: folds to the Chest slot bit (4)
|
||||
// 0 everywhere, so ties fall through to the name; the ItemLevel test sets it.
|
||||
product_item_level: 0,
|
||||
reagents: vec![TradeSkillReagent {
|
||||
item: 2589,
|
||||
@@ -35,9 +34,7 @@ fn recipe(
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-recipe, SINGLE-group Tailoring window (the original v1 fixture, now grouped under one
|
||||
/// "Cloth" header): row 1 is the header, rows 2-3 are the recipes, tier-sorted — Simple Robe
|
||||
/// (Optimal, tier 0) before Bolt of Linen Cloth (Trivial, tier 3).
|
||||
/// One "Cloth" group: row 1 the header, then Simple Robe (tier 0) and Bolt of Linen Cloth (tier 3).
|
||||
fn two_recipe_state() -> TradeSkillState {
|
||||
TradeSkillState {
|
||||
line: 197,
|
||||
@@ -64,11 +61,8 @@ fn two_recipe_state() -> TradeSkillState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Four groups (`0x4fca20`): class 1 "Bolts" (two Optimal-tier recipes — a NAME
|
||||
/// tie within a tier), class 2 "Armor Kit"/"Zephyr Cloak" (a CLASS tie broken by group NAME, not
|
||||
/// subclass id — "Zephyr Cloak"'s subclass id 1 is LOWER than "Armor Kit"'s 5, yet "Armor Kit"
|
||||
/// sorts first), and one recipe whose product template hasn't resolved yet (`group: None`),
|
||||
/// bucketed trailing under an empty header.
|
||||
/// Four groups: "Bolts" (class 1, a name tie within a tier); "Armor Kit" and "Zephyr Cloak"
|
||||
/// (class 2, ordered by name against their subclass ids 5 and 1); a recipe with no group yet.
|
||||
fn state() -> TradeSkillState {
|
||||
TradeSkillState {
|
||||
line: 197,
|
||||
@@ -137,7 +131,7 @@ fn grouped_visible_rows_ordered_by_class_then_name_tie_then_tier_then_name() {
|
||||
("Bolts", "header"),
|
||||
("Alpha Bolt", "optimal"), // tier tie -> name
|
||||
("Beta Bolt", "optimal"),
|
||||
("Armor Kit", "header"), // class tie -> NAME, not subclass id (5 > 1)
|
||||
("Armor Kit", "header"), // class tie -> name, not subclass id (5 > 1)
|
||||
("Alpha Plate", "optimal"), // tier 0 before...
|
||||
("Zinc Chain", "trivial"), // ...tier 3
|
||||
("Zephyr Cloak", "header"),
|
||||
@@ -160,8 +154,7 @@ fn collapse_hides_a_groups_entries_and_remaps_indices_incl_collapse_all() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(state()));
|
||||
|
||||
// Collapse "Armor Kit" (header at visible index 4): its two recipes vanish, 10 -> 8, and it
|
||||
// now reports isExpanded=nil.
|
||||
// "Armor Kit" is header 4: its two recipes go, 10 -> 8, and it reports isExpanded nil.
|
||||
s.run("CollapseTradeSkillSubClass(4)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 8);
|
||||
let (name, _, _, expanded) = s
|
||||
@@ -170,20 +163,18 @@ fn collapse_hides_a_groups_entries_and_remaps_indices_incl_collapse_all() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), expanded), ("Armor Kit", None));
|
||||
// "Zephyr Cloak" is now row 5 (was row 7) — the two hidden entries shift everything after.
|
||||
// "Zephyr Cloak" moves up from row 7 to row 5.
|
||||
assert_eq!(
|
||||
row_kind(&mut s, 5),
|
||||
("Zephyr Cloak".into(), "header".into())
|
||||
);
|
||||
|
||||
// Expand it back.
|
||||
s.run("ExpandTradeSkillSubClass(4)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 10);
|
||||
|
||||
// Collapse-all (id 0): only the 4 headers remain.
|
||||
// Id 0 folds every group: the 4 headers remain.
|
||||
s.run("CollapseTradeSkillSubClass(0)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 4);
|
||||
// Expand-all (id 0).
|
||||
s.run("ExpandTradeSkillSubClass(0)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 10);
|
||||
}
|
||||
@@ -193,8 +184,7 @@ fn pending_recipes_bucket_trailing_under_an_empty_header() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(state()));
|
||||
|
||||
// The pending header (row 9): empty name, "header", numAvailable 0, expanded by default —
|
||||
// a real group like any other (nothing has collapsed it).
|
||||
// Row 9 is the pending group's header.
|
||||
let (name, kind, avail, expanded) = s
|
||||
.eval::<(String, String, i64, Option<i64>)>(
|
||||
"local n,t,a,e = GetTradeSkillInfo(9) return n,t,a,e",
|
||||
@@ -204,10 +194,8 @@ fn pending_recipes_bucket_trailing_under_an_empty_header() {
|
||||
(name.as_str(), kind.as_str(), avail, expanded),
|
||||
("", "header", 0, Some(1))
|
||||
);
|
||||
// Its one recipe (row 10) reads normally.
|
||||
assert_eq!(row_kind(&mut s, 10), ("Mystery Item".into(), "easy".into()));
|
||||
|
||||
// It folds/unfolds exactly like a named group.
|
||||
s.run("CollapseTradeSkillSubClass(9)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 9);
|
||||
}
|
||||
@@ -217,7 +205,6 @@ fn header_and_entry_tuple_shapes() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(state()));
|
||||
|
||||
// Header row 1 ("Bolts"): (name, "header", 0, expanded=1).
|
||||
let (name, kind, avail, expanded) = s
|
||||
.eval::<(String, String, i64, Option<i64>)>(
|
||||
"local n,t,a,e = GetTradeSkillInfo(1) return n,t,a,e",
|
||||
@@ -228,7 +215,6 @@ fn header_and_entry_tuple_shapes() {
|
||||
("Bolts", "header", 0, Some(1))
|
||||
);
|
||||
|
||||
// Recipe row 2 ("Alpha Bolt", optimal, numAvailable 5): (name, "optimal", 5, nil).
|
||||
let (name, kind, avail, expanded) = s
|
||||
.eval::<(String, String, i64, Option<i64>)>(
|
||||
"local n,t,a,e = GetTradeSkillInfo(2) return n,t,a,e",
|
||||
@@ -245,8 +231,7 @@ fn do_trade_skill_and_getters_no_op_on_a_header_index() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(state()));
|
||||
|
||||
// Header row 1 ("Bolts"): every per-recipe getter reads nil/zero/empty rather than
|
||||
// misreading through to whatever recipe happens to sit at that raw position.
|
||||
// Row 1 is a header: every per-recipe getter reads nil, zero or empty.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetTradeSkillIcon(1) == nil")
|
||||
.unwrap());
|
||||
@@ -273,7 +258,7 @@ fn do_trade_skill_and_getters_no_op_on_a_header_index() {
|
||||
"a header index queues no craft"
|
||||
);
|
||||
|
||||
// SelectTradeSkill on a header index is IGNORED — the prior selection is untouched.
|
||||
// A header index leaves the selection as it was.
|
||||
s.run("SelectTradeSkill(2)").unwrap(); // "Alpha Bolt", row 2
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
|
||||
@@ -324,8 +309,7 @@ fn selection_persists_across_collapse_and_a_regroup() {
|
||||
8
|
||||
);
|
||||
|
||||
// Collapsing an EARLIER group ("Bolts", header 1) shifts every later row up two — the
|
||||
// selection follows Wind Cloak to its new visible position, not the stale numeric index.
|
||||
// Folding "Bolts" above it moves Wind Cloak up two rows, and the selection follows.
|
||||
s.run("CollapseTradeSkillSubClass(1)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 8);
|
||||
assert_eq!(
|
||||
@@ -335,15 +319,14 @@ fn selection_persists_across_collapse_and_a_regroup() {
|
||||
);
|
||||
s.run("ExpandTradeSkillSubClass(1)").unwrap();
|
||||
|
||||
// Collapsing Wind Cloak's OWN group ("Zephyr Cloak", back at row 7) hides the selected row:
|
||||
// the index reads 0 without discarding the underlying selection...
|
||||
// Folding its own group (row 7) hides it: the index reads 0, but the selection stays...
|
||||
s.run("CollapseTradeSkillSubClass(7)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
// ...expanding it again brings the SAME recipe right back into view at row 8.
|
||||
// ...and comes back at row 8.
|
||||
s.run("ExpandTradeSkillSubClass(7)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
|
||||
@@ -361,8 +344,7 @@ fn selection_persists_across_collapse_and_a_regroup() {
|
||||
8
|
||||
);
|
||||
|
||||
// A re-push that drops the selected recipe entirely (its whole group along with it) clears
|
||||
// the selection.
|
||||
// A re-push without the recipe clears the selection.
|
||||
let mut without_wind_cloak = state();
|
||||
without_wind_cloak.recipes.remove(4);
|
||||
s.set_trade_skill(Some(without_wind_cloak));
|
||||
@@ -383,8 +365,7 @@ fn snapshot_feeds_the_api_tuples_through_the_visible_mapping() {
|
||||
.unwrap(),
|
||||
("Tailoring".into(), 57, 75)
|
||||
);
|
||||
// 1 header + 2 recipes = 3 visible rows; the first NON-header row is 2 (Simple Robe, the
|
||||
// group's tier-0 recipe).
|
||||
// 1 header + 2 recipes; the first recipe row is 2 (Simple Robe, tier 0).
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 3);
|
||||
assert_eq!(s.eval::<i64>("return GetFirstTradeSkill()").unwrap(), 2);
|
||||
|
||||
@@ -437,7 +418,7 @@ fn selection_persists_across_a_repush_by_spell_id() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(two_recipe_state()));
|
||||
|
||||
// Select "Bolt of Linen Cloth" (spell 2963), the group's tier-3 row (row 3).
|
||||
// Bolt of Linen Cloth (spell 2963) is row 3.
|
||||
s.run("SelectTradeSkill(3)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
|
||||
@@ -445,9 +426,7 @@ fn selection_persists_across_a_repush_by_spell_id() {
|
||||
3
|
||||
);
|
||||
|
||||
// Re-push with the flat array reordered (a reagent-count re-list from the app) — the
|
||||
// ENGINE re-sorts regardless of push order, so Bolt of Linen Cloth stays visible row 3; the
|
||||
// selection follows it there by SPELL ID, not by its (now different) flat array position.
|
||||
// A reordered re-push: the engine re-sorts, and the selection follows the spell id to row 3.
|
||||
let mut reordered = two_recipe_state();
|
||||
reordered.recipes.swap(0, 1);
|
||||
s.set_trade_skill(Some(reordered));
|
||||
@@ -457,7 +436,7 @@ fn selection_persists_across_a_repush_by_spell_id() {
|
||||
3
|
||||
);
|
||||
|
||||
// A re-push that drops the selected recipe entirely clears the selection.
|
||||
// A re-push without the recipe clears the selection.
|
||||
let mut without_2963 = two_recipe_state();
|
||||
without_2963.recipes.remove(0);
|
||||
s.set_trade_skill(Some(without_2963));
|
||||
@@ -478,17 +457,14 @@ fn do_trade_skill_drains_spell_id_and_count() {
|
||||
assert_eq!(s.take_trade_skill_dos(), vec![(2963, 5)]);
|
||||
assert!(s.take_trade_skill_dos().is_empty(), "drained");
|
||||
|
||||
// Row 2 = Simple Robe (spell 3919, numAvailable 0). No count arg defaults to 1; a
|
||||
// non-positive count clamps up to 1 (the `DoTradeSkill 0x500280` latch floor —
|
||||
// `max(avail, 1)`).
|
||||
// Row 2 = Simple Robe (spell 3919, numAvailable 0): a missing or zero count is 1 (`0x500280`).
|
||||
s.run("DoTradeSkill(2) DoTradeSkill(2, 0)").unwrap();
|
||||
assert_eq!(s.take_trade_skill_dos(), vec![(3919, 1), (3919, 1)]);
|
||||
|
||||
// An out-of-range index is ignored.
|
||||
s.run("DoTradeSkill(99)").unwrap();
|
||||
assert!(s.take_trade_skill_dos().is_empty());
|
||||
|
||||
// A HEADER index (row 1, "Cloth") queues nothing either.
|
||||
// Row 1 is the "Cloth" header.
|
||||
s.run("DoTradeSkill(1, 5)").unwrap();
|
||||
assert!(s.take_trade_skill_dos().is_empty());
|
||||
}
|
||||
@@ -533,7 +509,6 @@ fn no_snapshot_shapes_unknown_line_and_nil_info() {
|
||||
s.run("DoTradeSkill(1, 1)").unwrap();
|
||||
assert!(s.take_trade_skill_dos().is_empty(), "no window, no intent");
|
||||
|
||||
// Collapse/expand/select on an empty pane are harmless no-ops.
|
||||
s.run("CollapseTradeSkillSubClass(0) ExpandTradeSkillSubClass(1) SelectTradeSkill(1)")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 0);
|
||||
@@ -557,24 +532,22 @@ fn get_trade_skill_tools_multivalue_shape() {
|
||||
("Anvil", Some(1), "Mining Pick", None)
|
||||
);
|
||||
|
||||
// A recipe with no tools returns an empty multivalue (arity 0, not one nil).
|
||||
// No tools: zero values, not one nil.
|
||||
let mut t2 = two_recipe_state();
|
||||
t2.recipes[0].tools.clear();
|
||||
s.set_trade_skill(Some(t2));
|
||||
assert_eq!(s.arity("GetTradeSkillTools(3)").unwrap(), 0);
|
||||
|
||||
// A HEADER index (row 1) also returns an empty multivalue.
|
||||
// A header row: zero values too.
|
||||
assert_eq!(s.arity("GetTradeSkillTools(1)").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The verified persistence story (`0x4fc910`, the `0xbde064` cache key):
|
||||
/// collapse/filter/selection state SURVIVES a same-profession close→reopen and resets only
|
||||
/// when a DIFFERENT skill line opens.
|
||||
/// Persistence keyed by skill line (`0x4fc910`, `0xbde064`).
|
||||
#[test]
|
||||
fn close_reopen_keeps_state_for_the_same_line_and_resets_on_a_switch() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(two_recipe_state()));
|
||||
s.run("SelectTradeSkill(2)").unwrap(); // Simple Robe (the tier-0 row under the header)
|
||||
s.run("SelectTradeSkill(2)").unwrap(); // Simple Robe, the tier-0 row
|
||||
s.run("CollapseTradeSkillSubClass(1)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 1);
|
||||
|
||||
@@ -591,10 +564,10 @@ fn close_reopen_keeps_state_for_the_same_line_and_resets_on_a_switch() {
|
||||
0
|
||||
);
|
||||
|
||||
// Reopen the SAME line: the fold survives the round trip (header only, still collapsed)…
|
||||
// The same line: the fold survives...
|
||||
s.set_trade_skill(Some(two_recipe_state()));
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 1);
|
||||
// …and so does the selection, by spell id: expand and it's visible index 2 again.
|
||||
// ...and so does the selection, by spell id.
|
||||
s.run("ExpandTradeSkillSubClass(1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
|
||||
@@ -602,7 +575,7 @@ fn close_reopen_keeps_state_for_the_same_line_and_resets_on_a_switch() {
|
||||
2
|
||||
);
|
||||
|
||||
// A DIFFERENT line resets everything: fully expanded, nothing selected.
|
||||
// A different line resets everything.
|
||||
s.run("CollapseTradeSkillSubClass(1)").unwrap();
|
||||
let mut other = two_recipe_state();
|
||||
other.line = 164; // Blacksmithing
|
||||
@@ -615,9 +588,7 @@ fn close_reopen_keeps_state_for_the_same_line_and_resets_on_a_switch() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The SubClass filter (ref Blizzard_TradeSkillUI.lua l.406-408): an exclusive set shows only
|
||||
/// that group; the vocabulary (`GetTradeSkillSubClasses`) never shrinks; `Get(0)` answers the
|
||||
/// all-shown probe; `Set(0, 1, 1)` (the "All Subclasses" row) restores everything.
|
||||
/// The subclass filter as the stock menu drives it (`Blizzard_TradeSkillUI.lua:406-409`).
|
||||
#[test]
|
||||
fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -652,7 +623,7 @@ fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() {
|
||||
1
|
||||
);
|
||||
|
||||
// The "All Subclasses" row's own call shape restores everything.
|
||||
// The "All Subclasses" row's call restores everything.
|
||||
s.run("SetTradeSkillSubClassFilter(0, 1, 1)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 10);
|
||||
assert_eq!(
|
||||
@@ -662,12 +633,9 @@ fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A stand-in string table for the `0x84dd70` token family — **deliberately not the shipped
|
||||
/// wording**, because what these tests establish is *which token* each slot bit reaches, never
|
||||
/// what the word says (decision 2045, "assert the identifier, not the sentence"). Naming each
|
||||
/// value after its own key is the point: `SECONDARYHANDSLOT`, `INVTYPE_SHIELD` and
|
||||
/// `INVTYPE_WEAPONOFFHAND` all read "Off Hand" in enUS, so an assertion on the English could not
|
||||
/// tell a correct table from one wired to the item tooltip's family.
|
||||
/// Stand-in slot strings named after their tokens (`0x84dd70`): `SECONDARYHANDSLOT`,
|
||||
/// `INVTYPE_SHIELD` and `INVTYPE_WEAPONOFFHAND` all read "Off Hand" in enUS, so only the token
|
||||
/// shows which one a bit reaches.
|
||||
fn seed_slot_tokens(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
@@ -687,9 +655,6 @@ fn seed_slot_tokens(s: &mut UiScript) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The InvSlot filter: the vocabulary is the distinct slot words ascending by slot bit; an
|
||||
/// exclusive set drops every recipe on other slots AND any group that empties — header
|
||||
/// included.
|
||||
#[test]
|
||||
fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -728,9 +693,7 @@ fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 10);
|
||||
}
|
||||
|
||||
/// WEAPON (InventoryType 13) is the one multi-bit contribution (`0x18000`): a
|
||||
/// one-hand weapon lists BOTH hand slots in the vocabulary and stays visible under either
|
||||
/// hand's exclusive filter.
|
||||
/// A one-hand weapon (InventoryType 13, `0x18000`) lists both hand slots and passes either.
|
||||
#[test]
|
||||
fn one_hand_weapon_spans_both_hand_slots() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -758,8 +721,8 @@ fn one_hand_weapon_spans_both_hand_slots() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every engine-side list mutator raises the touched flag exactly once per drain — the app's
|
||||
/// cue to fire TRADE_SKILL_UPDATE (the `0x4fd710`/`0x4fd750` in-call event, module doc).
|
||||
/// Each filter or fold raises the flag the app answers with TRADE_SKILL_UPDATE (`0x4fd710`,
|
||||
/// `0x4fd750`).
|
||||
#[test]
|
||||
fn filter_and_fold_mutators_raise_the_touched_flag() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -779,10 +742,7 @@ fn filter_and_fold_mutators_raise_the_touched_flag() {
|
||||
assert!(s.take_trade_skill_touched());
|
||||
}
|
||||
|
||||
/// The sort's SECONDARY key is the product's ItemLevel, ascending — between the difficulty tier
|
||||
/// and the name (the `record+0x14` = item template ItemLevel identity, pinned 2026-07-17). Two
|
||||
/// same-tier recipes whose ALPHABETICAL order contradicts their item levels must order by level;
|
||||
/// equal levels fall through to the name.
|
||||
/// ItemLevel (`record+0x14`) sorts between tier and name.
|
||||
#[test]
|
||||
fn same_tier_recipes_order_by_product_item_level_before_name() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -828,17 +788,12 @@ fn same_tier_recipes_order_by_product_item_level_before_name() {
|
||||
assert_eq!(names, ["Cloth", "Mmm Robe", "Zzz Robe", "Aaa Robe"]);
|
||||
}
|
||||
|
||||
/// The link pair (`0x4ff410`/`0x4ff800`, decision 1973): the product's link in its
|
||||
/// quality colour with zero tokens; ZERO values for a header, a missing product or an uncached
|
||||
/// template; the reagent link nil on the same misses and always exactly one value; the typo'd
|
||||
/// reagent Usage; the number gate.
|
||||
/// The two link verbs' shapes (`0x4ff410`, `0x4ff800`).
|
||||
#[test]
|
||||
fn the_link_verbs_answer_the_clients_shapes() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trade_skill(Some(state()));
|
||||
// Row 1 is a header; row 2 the first VISIBLE recipe — found by the name the API answers for
|
||||
// it, since the visible order is the grouped one, not the pushed one. Seed its product's and
|
||||
// one reagent's templates.
|
||||
// Row 2 is the first recipe, found by name since the visible order is the sorted one.
|
||||
let st = state();
|
||||
let row2 = s.eval::<String>("return (GetTradeSkillInfo(2))").unwrap();
|
||||
let r = st
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The display-tree + filter machinery: grouping/sorting the flat `TradeSkillRecipe` list into the
|
||||
//! visible-row tree the Era API indexes (see the parent module doc — the grouped-list law and the
|
||||
//! byte-VERIFIED `SubClassFilter`/`InvSlot` filter family, `0x4fca20`/`0x4fd180`).
|
||||
//! The visible rows the tradeskill API indexes: grouping, sorting and filtering the flat recipes
|
||||
//! (`0x4fca20`, `0x4fd180`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -8,13 +7,9 @@ use crate::script::Model;
|
||||
|
||||
use super::TradeSkillRecipe;
|
||||
|
||||
/// Fold a product's `InventoryType` to its InvSlot-filter contribution (`record+0x10`) — the
|
||||
/// real client's 29-entry `DAT_00809200` table with its build-time overrides, dumped in full and
|
||||
/// byte-VERIFIED (`0x809200`): FINGER/TRINKET/BAG collapse to their first slot's
|
||||
/// single bit (`0xb → 0x400`, `0xc → 0x1000`, `0x12 → 0x80000` — the raw table's multi-slot
|
||||
/// entries are dead, overwritten before use), zero contributions (NON_EQUIP/AMMO/QUIVER) take the
|
||||
/// `0x800000` catch-all (bit 23), and **WEAPON (13) is the one multi-bit survivor** — `0x18000`,
|
||||
/// a one-hand weapon shows under both hand slots.
|
||||
/// A product's `InventoryType` as inv-slot filter bits (`record+0x10`): the client's 29-entry
|
||||
/// table at `0x809200` after its overrides, so finger, trinket and bag take one bit each, a
|
||||
/// one-hand weapon sets both hand bits, and non-equipment takes the catch-all bit 23.
|
||||
fn inv_slot_mask(inv_type: u32) -> u32 {
|
||||
match inv_type {
|
||||
1 => 1 << 0, // HEAD
|
||||
@@ -27,34 +22,22 @@ fn inv_slot_mask(inv_type: u32) -> u32 {
|
||||
8 => 1 << 7, // FEET
|
||||
9 => 1 << 8, // WRIST
|
||||
10 => 1 << 9, // HANDS
|
||||
11 => 1 << 10, // FINGER (override — single bit)
|
||||
12 => 1 << 12, // TRINKET (override — single bit)
|
||||
13 => (1 << 15) | (1 << 16), // WEAPON — both hands (0x18000)
|
||||
11 => 1 << 10, // FINGER (override: single bit)
|
||||
12 => 1 << 12, // TRINKET (override: single bit)
|
||||
13 => (1 << 15) | (1 << 16), // WEAPON: both hands (0x18000)
|
||||
14 | 22 | 23 => 1 << 16, // SHIELD / WEAPONOFFHAND / HOLDABLE
|
||||
15 | 25 | 26 | 28 => 1 << 17, // RANGED / THROWN / RANGEDRIGHT / RELIC
|
||||
16 => 1 << 14, // CLOAK (back)
|
||||
17 | 21 => 1 << 15, // 2HWEAPON / WEAPONMAINHAND
|
||||
18 => 1 << 19, // BAG (override — single bit)
|
||||
18 => 1 << 19, // BAG (override: single bit)
|
||||
19 => 1 << 18, // TABARD
|
||||
_ => 1 << 23, // NON_EQUIP / AMMO / QUIVER / unknown — the catch-all
|
||||
_ => 1 << 23, // NON_EQUIP / AMMO / QUIVER / unknown: the catch-all
|
||||
}
|
||||
}
|
||||
|
||||
/// The InvSlot dropdown's per-bit GlobalString **token** — the real client's 24-entry table
|
||||
/// (`0x84dd70`), dumped entry by entry. The caller
|
||||
/// resolves it against the player's own `GlobalStrings.lua` (decision 2045); these are the
|
||||
/// paper-doll `*SLOT` family, **not** the `INVTYPE_*` family the item tooltip's slot line uses,
|
||||
/// and that distinction is exactly why the word cannot be stored here: `SECONDARYHANDSLOT`,
|
||||
/// `INVTYPE_SHIELD` and `INVTYPE_WEAPONOFFHAND` are three separately-localizable strings that all
|
||||
/// read "Off Hand" in enUS.
|
||||
///
|
||||
/// Bits 11/13/20-22 (`FINGER1SLOT`/`TRINKET1SLOT`/the extra `BAGSLOT`s) are present in the table
|
||||
/// but unreachable through [`inv_slot_mask`] — kept for the full 24-entry fidelity.
|
||||
///
|
||||
/// **`None` is the out-of-range arm and is unreachable by construction**: the only caller walks
|
||||
/// [`present_inv_slots`], which enumerates bits 0..23, and the reference's table has an entry for
|
||||
/// every one of them. It resolves to the empty string at the call site, which is what
|
||||
/// `FrameScript_GetText` hands back for a token the table has no row for.
|
||||
/// The inv-slot dropdown's `GlobalStrings.lua` token per bit, the client's 24-entry table at
|
||||
/// `0x84dd70`: the paper-doll `*SLOT` names, not the tooltip's `INVTYPE_*` ones. Bits 11, 13 and
|
||||
/// 20-22 are in the table, but no `InventoryType` reaches them.
|
||||
pub(super) fn inv_slot_token(bit: u32) -> Option<&'static str> {
|
||||
Some(match bit {
|
||||
0 => "HEADSLOT",
|
||||
@@ -82,10 +65,8 @@ pub(super) fn inv_slot_token(bit: u32) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The InvSlot filter vocabulary: the set bits of the accumulated slot mask, ascending — the real
|
||||
/// client's bits-0..23 walk over `0xbde058` (`GetTradeSkillInvSlots 0x4ffc20`).
|
||||
/// Accumulated over ALL recipes, unfiltered — the mask ORs up at list build, before any filter
|
||||
/// applies.
|
||||
/// The inv-slot vocabulary: the set bits, ascending, of the slot mask over all recipes, unfiltered
|
||||
/// (`0xbde058`, walked by `GetTradeSkillInvSlots`, `0x4ffc20`).
|
||||
pub(super) fn present_inv_slots(model: &Model) -> Vec<u32> {
|
||||
let Some(t) = model.trade_skill.as_ref() else {
|
||||
return Vec::new();
|
||||
@@ -97,9 +78,8 @@ pub(super) fn present_inv_slots(model: &Model) -> Vec<u32> {
|
||||
(0..24).filter(|b| accum & (1 << b) != 0).collect()
|
||||
}
|
||||
|
||||
/// Whether a recipe passes the current filters (the recompute's own row test, `0x4fd180`): its slot
|
||||
/// contribution overlaps the shown-mask (`record+0x10 & 0x84dd64 != 0`) and its group isn't
|
||||
/// subclass-hidden.
|
||||
/// The recompute's row test (`0x4fd180`): the slot bits meet the shown mask (`0x84dd64`) and the
|
||||
/// group is not hidden.
|
||||
fn passes_filters(model: &Model, r: &TradeSkillRecipe) -> bool {
|
||||
let key = r
|
||||
.group
|
||||
@@ -109,40 +89,27 @@ fn passes_filters(model: &Model, r: &TradeSkillRecipe) -> bool {
|
||||
&& inv_slot_mask(r.product_inv_type) & model.trade_skill_invslot_mask != 0
|
||||
}
|
||||
|
||||
/// One recipe group in the synthesized display tree (`0x4fca20`): the created item's
|
||||
/// `(ItemClass, ItemSubClass)` key (the collapse key) + resolved display name, and the positions
|
||||
/// (into [`TradeSkillState::recipes`]) of the group's recipes, pre-sorted by tier, product
|
||||
/// ItemLevel, then name.
|
||||
/// Engine-internal — rebuilt fresh from the flat recipes on every query ([`build_groups`]) rather
|
||||
/// than cached on [`Model`] (unlike [`super::skills::SkillGroup`]): `TradeSkillState` has no
|
||||
/// engine-owned tree field of its own, so [`Row::Header`] just carries the group's key/name
|
||||
/// directly instead of an index into a cached `Vec`.
|
||||
/// One recipe group (`0x4fca20`), keyed by the product's `(ItemClass, ItemSubClass)` and rebuilt
|
||||
/// on every query.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct TradeSkillGroup {
|
||||
pub(super) key: (u32, u32),
|
||||
pub(super) name: String,
|
||||
/// Positions into [`TradeSkillState::recipes`], sorted by tier, product ItemLevel, then
|
||||
/// [`collate`]d name.
|
||||
/// Positions into `TradeSkillState::recipes`, in display order.
|
||||
entries: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The WoW enUS collator, approximated ([`super::skills`]/[`super::trainer`]'s own helper,
|
||||
/// duplicated here rather than shared — each seam module keeps its own copy, the established local
|
||||
/// convention): case-insensitive alphabetical, raw bytes as a stable tie-break.
|
||||
/// An approximation of the reference's name compare, the case-insensitive `0x64a4c0`:
|
||||
/// case-insensitive, then raw bytes.
|
||||
fn collate(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
a.to_lowercase()
|
||||
.cmp(&b.to_lowercase())
|
||||
.then_with(|| a.cmp(b))
|
||||
}
|
||||
|
||||
/// Build the display tree from the flat recipes (the module doc's grouped-list law,
|
||||
/// `0x4fca20`): group by the created item's `(ItemClass, ItemSubClass)` — a recipe with no
|
||||
/// group yet (`group: None`, its product template still in flight) buckets into the trailing
|
||||
/// `(u32::MAX, u32::MAX)`/`""` group; sort each group's recipes by tier, then the product's
|
||||
/// **ItemLevel ascending** (the `record+0x14` secondary key — field identity pinned 2026-07-17,
|
||||
/// [`TradeSkillRecipe::product_item_level`]), then [`collate`]d name; sort the groups by
|
||||
/// `ItemClass` id ascending, ties broken by [`collate`]d name (never by subclass id — the pending
|
||||
/// group's `u32::MAX` class id sorts it last with no special-casing needed).
|
||||
/// The groups in display order (`0x4fca20`): by `ItemClass`, then name, never subclass id; each
|
||||
/// group's recipes by tier, product ItemLevel (`record+0x14`), then name. A recipe with no group
|
||||
/// yet goes into an empty-named group keyed `u32::MAX`, which sorts last.
|
||||
pub(super) fn build_groups(recipes: &[TradeSkillRecipe]) -> Vec<TradeSkillGroup> {
|
||||
let mut map: HashMap<(u32, u32), TradeSkillGroup> = HashMap::new();
|
||||
for (i, r) in recipes.iter().enumerate() {
|
||||
@@ -183,22 +150,16 @@ pub(super) fn build_groups(recipes: &[TradeSkillRecipe]) -> Vec<TradeSkillGroup>
|
||||
groups
|
||||
}
|
||||
|
||||
/// One visible row of the display tree: a group **header** (carrying its key/name — see
|
||||
/// [`TradeSkillGroup`]'s own doc for why this isn't an index) or a **recipe** (carrying its
|
||||
/// position into [`TradeSkillState::recipes`]).
|
||||
/// One visible row: a group header, or a recipe's position in `TradeSkillState::recipes`.
|
||||
#[derive(Clone)]
|
||||
pub(super) enum Row {
|
||||
Header { key: (u32, u32), name: String },
|
||||
Entry(usize),
|
||||
}
|
||||
|
||||
/// The visible rows in display order: each group's header, then — when the group isn't
|
||||
/// collapsed — its recipes that pass the filters ([`passes_filters`]). A subclass-hidden group,
|
||||
/// or one whose every recipe the InvSlot filter drops, contributes NO rows at all — header
|
||||
/// included (the real window never shows an empty header under a filter — the visibility law
|
||||
/// decision 0452 landed byte-exact, `0x4fd180`: filtered recipes and emptied/
|
||||
/// filtered headers drop out of the numbered list; a merely-collapsed header stays). The Lua's
|
||||
/// 1-based `index` is a position in *this* list. Empty when no window is open.
|
||||
/// The visible rows every Lua index is 1-based into: each group's header, then its recipes that
|
||||
/// pass the filters unless it is folded. A group the filters empty loses its header too; a merely
|
||||
/// folded one keeps it (`0x4fd180`).
|
||||
pub(super) fn rows(model: &Model) -> Vec<Row> {
|
||||
let Some(t) = model.trade_skill.as_ref() else {
|
||||
return Vec::new();
|
||||
@@ -230,19 +191,12 @@ pub(super) fn rows(model: &Model) -> Vec<Row> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The count of visible rows (headers + the recipes of uncollapsed groups).
|
||||
pub(super) fn num_rows(model: &Model) -> usize {
|
||||
rows(model).len()
|
||||
}
|
||||
|
||||
/// The recipe at a 1-based VISIBLE index, or `None` when that row is a header (or OOB) — so every
|
||||
/// per-recipe getter/`DoTradeSkill` safely no-ops on a header row (`0x4fca20`).
|
||||
///
|
||||
/// `pub(crate)`: the tooltip channel (`super::tooltip_item`'s `SetTradeSkillItem`) SHOULD route
|
||||
/// through this too — a header index, or the header rows preceding a real recipe, would otherwise
|
||||
/// misalign a raw `TradeSkillState::recipes` index against the visible list the XML actually clicks
|
||||
/// through. See the module doc's "KNOWN GAP" note: that follow-up isn't done here (out of this
|
||||
/// change's file scope), but the seam is exposed for it.
|
||||
/// The recipe at a 1-based visible index; `None` on a header, so the per-recipe verbs and
|
||||
/// `SetTradeSkillItem` no-op there.
|
||||
pub(crate) fn recipe_at(model: &Model, index: usize) -> Option<&TradeSkillRecipe> {
|
||||
let n = index.checked_sub(1)?;
|
||||
match rows(model).get(n)? {
|
||||
@@ -251,7 +205,7 @@ pub(crate) fn recipe_at(model: &Model, index: usize) -> Option<&TradeSkillRecipe
|
||||
}
|
||||
}
|
||||
|
||||
/// The first NON-header visible index (`GetFirstTradeSkill`'s law), `0` when none.
|
||||
/// `GetFirstTradeSkill`: the first recipe row's visible index, 0 when none.
|
||||
pub(super) fn first_recipe_index(model: &Model) -> u32 {
|
||||
rows(model)
|
||||
.iter()
|
||||
@@ -259,13 +213,8 @@ pub(super) fn first_recipe_index(model: &Model) -> u32 {
|
||||
.map_or(0, |p| (p + 1) as u32)
|
||||
}
|
||||
|
||||
/// `GetTradeSkillSelectionIndex()` — the CURRENT visible index of the engine-held selection (`0`
|
||||
/// when nothing is selected, or the selected recipe isn't visible right now, e.g. its group just got
|
||||
/// collapsed). [`Model::trade_skill_selection`] holds a stable position into the open window's FLAT
|
||||
/// `recipes` list (module doc) — collapse/expand never touches it, only what [`rows`] shows, so this
|
||||
/// derives the visible position fresh on every read rather than needing an eager remap on every
|
||||
/// collapse toggle ([`super::skills`]'s own by-identity pattern, adapted to a field this module can't
|
||||
/// change the type of).
|
||||
/// `GetTradeSkillSelectionIndex`: the selection's visible index, 0 when none or hidden. The
|
||||
/// selection is held as a flat recipe position, so a fold never moves it.
|
||||
pub(super) fn selected_visible_index(model: &Model) -> u32 {
|
||||
let Some(pos) = model.trade_skill_selection.checked_sub(1) else {
|
||||
return 0;
|
||||
@@ -276,19 +225,15 @@ pub(super) fn selected_visible_index(model: &Model) -> u32 {
|
||||
.map_or(0, |p| (p + 1) as u32)
|
||||
}
|
||||
|
||||
/// `SelectTradeSkill(index)` — resolve the 1-based VISIBLE index to a position in the open window's
|
||||
/// flat recipe list and hold THAT ([`selected_visible_index`]'s own doc). A HEADER row is IGNORED —
|
||||
/// the prior selection is left exactly as it was (the module doc: the ref's own `SetSelection`
|
||||
/// toggles the fold instead of selecting, so a header index never reaches here through the XML in
|
||||
/// practice, but the engine contract holds regardless). An out-of-range index clears the selection
|
||||
/// (the pre-existing convention, unchanged).
|
||||
/// `SelectTradeSkill`: holds the recipe at a visible index. A header index leaves the selection
|
||||
/// as it was, and an out-of-range one clears it; the stock Lua folds a header instead of selecting
|
||||
/// it (`Blizzard_TradeSkillUI.lua:184-192`).
|
||||
pub(super) fn select(model: &mut Model, index: u32) {
|
||||
let visible = rows(model);
|
||||
match index.checked_sub(1).and_then(|n| visible.get(n as usize)) {
|
||||
Some(Row::Entry(ei)) => {
|
||||
model.trade_skill_selection = (*ei + 1) as u32;
|
||||
// The spell-id shadow (`0xbde044`'s own storage) — what survives a close→reopen and
|
||||
// remaps across a re-push ([`super::UiScript::set_trade_skill`]).
|
||||
// The spell id (`0xbde044`) is what survives a close and remaps across a re-push.
|
||||
model.trade_skill_selected_spell = model
|
||||
.trade_skill
|
||||
.as_ref()
|
||||
@@ -303,11 +248,8 @@ pub(super) fn select(model: &mut Model, index: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse (`collapse = true`) or expand a group by the **display index of its header row**
|
||||
/// ([`super::skills`]'s own `Collapse/ExpandSkillHeader` shape). `id == 0` targets ALL groups (the
|
||||
/// CollapseAll semantics — no such button ships this slice, the XML's own deviation note); `id > 0`
|
||||
/// resolves the header at that visible index to its group key. A non-header (or OOB) index is a
|
||||
/// no-op.
|
||||
/// Folds or unfolds the group whose header is visible row `id`; 0 is every group, as the stock
|
||||
/// collapse-all button passes, and any other row is a no-op.
|
||||
pub(super) fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
|
||||
if id == 0 && !collapse {
|
||||
model.trade_skill_collapsed.clear();
|
||||
|
||||
Reference in New Issue
Block a user