Files
benilla-octo/crates/benilla-ui/src/script/tradeskill/api.rs
T
SamandClaude Fable 5.1 c965b5425d the tooltip and five windows go stock, and the battleground keeps score
- the tooltip is the reference's own file: `GameTooltip.xml` loads off the chain, ours is deleted, and every verb it calls was already bound, so this is a plain swap; retired with it: the `$parentThicken` plate fill that thickened the plate from the art's ~73% to ~84% opacity — the reference's plate is the raw `UI-Tooltip-Background` tint, and that is what ships; the one engine fact the transcription had been hiding: the reference's instance declares no size, and a frame with one anchor and no size resolves to NO rect under the reference's own layout law — our declared size had been a harness crutch (1968); and the dressing room goes stock with `<DressUpModel>` as a widget kind (factory `0x495c00`): a `PlayerModel` plus its own `Undress`/`Dress`/`TryOn`, where `Undress` strips the body and not the hands, and Close is read off the window since the stock OnHide queues nothing (1969)
- the mail window and the auction house are the reference's own: `MailFrame.xml` loads off the chain and brings the stationery picker, invoice pane and autocomplete ours had left out; the stationery family is built off wow-re's carve over a list computed off `Stationery.dbc` rows × the player's bags (`0x622270`, not the bank); the selection rides `CMSG_SEND_MAIL` field 6 and a send with none silently aborts; its two events fire where the client fires them (1970); `Blizzard_AuctionUI` loads on demand from the `AUCTION_HOUSE_SHOW` arm, and three root fixes its Lua surfaced: `GetAuctionItemClasses()` is static — read at the addon's load, before any session, so the class tree is a login-scoped push; `SetAuctionCompareItem` (`0x535d70`) on the vendor compare's one body; `NEW_AUCTION_UPDATE` resets the create form; its dressing-room pane shares the app's one room (1971)
- the battleground keeps score: `WorldStateFrame.xml` loads off the chain and ours is deleted; the stock file is three things where ours was one: the readout, the capture bars, and the battleground scoreboard this client never had, built off wow-re's round — `MSG_PVP_LOG_DATA` both ways (throttled to 5000 ms client-side, `0x4aa170`) and `CMSG_LEAVE_BATTLEFIELD`; eleven score verbs over a board the app pushes, where the filter is a SORT, not a compaction — team first, killing blows descending, deaths ascending, honor descending, name — so the filtered count shrinks while `GetBattlefieldScore` still reaches every wire row; `HIDE_OUTDOOR_WORLD_STATE` joins the options uvar block, a capture point is a bar, and the scoreboard and battlefield-minimap toggles become bound specs at the install's own `SHIFT-SPACE`/`SHIFT-M` (1972)
- the trade skill and craft windows go stock as `Blizzard_TradeSkillUI`/`Blizzard_CraftUI` on demand, from the reference's own UIParent arms; our two transcriptions are deleted; the four item-link verbs are built off wow-re's carve on the client's link builder (`0x52adb0`) — zero values on any miss, `GetTradeSkillReagentItemLink` raising Blizzard's own typo'd Usage, kept, and `GetCraftItemLink` the `|Henchant:` link in fixed white; `SetTextColor`'s channels coerce nil to 0 on FontString, Font and Button where ours had raised (1973); and the Inspect, Talent, Macro and Raid addons load on demand — `benilla.toc` lists no `Interface\AddOns` row, and below level 10 the talent addon is never even loaded; the gates walk every reached addon and found `Blizzard_GMSurveyUI`'s four engine verbs nothing answers, reachable only through `GMSURVEY_DISPLAY`, which nothing fires (1889; 1967)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:59:04 +07:00

508 lines
22 KiB
Rust

//! The Lua globals (`GetTradeSkillInfo`, `DoTradeSkill`, the filter family, …) — [`install`] wires
//! the Era `TradeSkillFrame.lua` API surface onto [`super::view`]'s display-tree/filter machinery.
use mlua::{Lua, MultiValue, Value};
use crate::script::binding_abi::number_arg;
use crate::script::item_stats::item_link;
use crate::script::Model;
use super::view::{
build_groups, first_recipe_index, inv_slot_name, num_rows, present_inv_slots, recipe_at, rows,
select, selected_visible_index, set_collapsed, Row,
};
/// An optional string as a Lua value (`nil` when absent).
fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
Ok(match s {
Some(s) => Value::String(lua.create_string(s)?),
None => Value::Nil,
})
}
/// A `bool` as the Era `1`/`nil` shape.
fn era_bool(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
/// Register the tradeskill globals.
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).
g.set(
"GetTradeSkillLine",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (name, rank, max_rank) = match &model.trade_skill {
Some(t) => (t.line_name.clone(), t.rank, t.max_rank),
None => ("UNKNOWN".to_string(), 0, 0),
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&name)?),
Value::Integer(i64::from(rank)),
Value::Integer(i64::from(max_rank)),
]))
})?,
)?;
// → the number of VISIBLE rows the open window offers — headers + the recipes of uncollapsed
// groups (0 when closed).
g.set(
"GetNumTradeSkills",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(num_rows(&model) as i64)
})?,
)?;
// The two link verbs (wow-re `tradeskill/scratch/tradeskill-craft-item-links.md`, 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.
g.set(
"GetTradeSkillItemLink",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetTradeSkillItemLink(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let link = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|n| rows(&model).get(n).cloned())
.and_then(|row| match row {
Row::Header { .. } => None,
Row::Entry(ei) => model.trade_skill.as_ref().map(|t| &t.recipes[ei]),
})
.filter(|r| r.product_item != 0)
.and_then(|r| {
model
.item_templates
.get(&r.product_item)
.map(|t| item_link(r.product_item, &t.name, t.quality))
});
Ok(match link {
Some(l) => MultiValue::from_vec(vec![Value::String(lua.create_string(&l)?)]),
None => MultiValue::new(),
})
})?,
)?;
// 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.
g.set(
"GetTradeSkillReagentItemLink",
lua.create_function(|lua, (index, reagent): (Value, Value)| {
let usage = "Usage: GetTradeReagentSkillItemLink(index, reagentIndex)";
let index = number_arg(lua, index, usage)?;
let reagent = number_arg(lua, reagent, usage)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let link = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|n| rows(&model).get(n).cloned())
.and_then(|row| match row {
Row::Header { .. } => None,
Row::Entry(ei) => model.trade_skill.as_ref().map(|t| &t.recipes[ei]),
})
.zip(usize::try_from(reagent).ok().and_then(|r| r.checked_sub(1)))
.and_then(|(r, ri)| r.reagents.get(ri))
.and_then(|re| {
model
.item_templates
.get(&re.item)
.map(|t| item_link(re.item, &t.name, t.quality))
});
Ok(match link {
Some(l) => Value::String(lua.create_string(&l)?),
None => Value::Nil,
})
})?,
)?;
// GetTradeSkillInfo(index) → name, type, numAvailable, isExpanded. `index` 1-based into the
// VISIBLE row list (the module doc's grouped-list law, wow-re `tradeskill` TU-B). A header row:
// (groupName, "header", 0, isExpanded 1/nil). A recipe row: (name, difficulty color key,
// numAvailable, nil). Out of range → a single nil.
g.set(
"GetTradeSkillInfo",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(n) = index.checked_sub(1) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let Some(row) = rows(&model).get(n).cloned() else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
match row {
Row::Header { key, name } => {
let expanded = !model.trade_skill_collapsed.contains(&key);
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&name)?),
Value::String(lua.create_string("header")?),
Value::Integer(0),
era_bool(expanded),
]))
}
Row::Entry(ei) => {
let r = &model
.trade_skill
.as_ref()
.expect("rows() only yields Entry rows when trade_skill is Some")
.recipes[ei];
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&r.name)?),
Value::String(lua.create_string(r.difficulty.as_str())?),
Value::Integer(i64::from(r.num_available)),
Value::Nil,
]))
}
}
})?,
)?;
// GetFirstTradeSkill() → the first NON-header visible index (0 when none).
g.set(
"GetFirstTradeSkill",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(first_recipe_index(&model)))
})?,
)?;
// GetTradeSkillSubClasses() → the current group names, in group order (VERIFIED, wow-re
// `tradeskill` TU-B: 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).
g.set(
"GetTradeSkillSubClasses",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
if let Some(t) = model.trade_skill.as_ref() {
for grp in build_groups(&t.recipes) {
out.push(Value::String(lua.create_string(&grp.name)?));
}
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// ExpandTradeSkillSubClass(i) / CollapseTradeSkillSubClass(i) — fold a group by its header's
// VISIBLE index (i == 0 = ALL groups, the CollapseAll semantics — see [`set_collapsed`]).
g.set(
"ExpandTradeSkillSubClass",
lua.create_function(|lua, id: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_collapsed(&mut model, id, false);
model.trade_skill_touched = true;
Ok(())
})?,
)?;
g.set(
"CollapseTradeSkillSubClass",
lua.create_function(|lua, id: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_collapsed(&mut model, id, true);
model.trade_skill_touched = true;
Ok(())
})?,
)?;
// GetTradeSkillIcon(index) → icon texture path (nil while in flight / OOB / a header row).
g.set(
"GetTradeSkillIcon",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
opt_str(lua, recipe_at(&model, index).and_then(|r| r.icon.as_ref()))
})?,
)?;
// GetTradeSkillNumMade(index) → minMade, maxMade (0, 0 when OOB / no window / a header row).
g.set(
"GetTradeSkillNumMade",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (min_made, max_made) =
recipe_at(&model, index).map_or((0, 0), |r| (r.min_made, r.max_made));
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(min_made)),
Value::Integer(i64::from(max_made)),
]))
})?,
)?;
// GetTradeSkillCooldown(index) → remaining cooldown seconds, or nil when ready / OOB / a header
// row (the ref Lua tests this return for truthiness).
g.set(
"GetTradeSkillCooldown",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(
match recipe_at(&model, index).and_then(|r| r.cooldown_secs) {
Some(secs) => Value::Integer(secs as i64),
None => Value::Nil,
},
)
})?,
)?;
// GetTradeSkillNumReagents(index) → this recipe's reagent count (0 when OOB / no window / a
// header row).
g.set(
"GetTradeSkillNumReagents",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(recipe_at(&model, index).map_or(0, |r| r.reagents.len()) as i64)
})?,
)?;
// 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.
g.set(
"GetTradeSkillReagentInfo",
lua.create_function(|lua, (index, reagent_index): (usize, usize)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(reagent) = recipe_at(&model, index)
.and_then(|r| reagent_index.checked_sub(1).and_then(|n| r.reagents.get(n)))
else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
opt_str(lua, reagent.name.as_ref())?,
opt_str(lua, reagent.icon.as_ref())?,
Value::Integer(i64::from(reagent.need)),
Value::Integer(i64::from(reagent.have)),
]))
})?,
)?;
// 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.
g.set(
"GetTradeSkillTools",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
if let Some(r) = recipe_at(&model, index) {
for (name, have) in &r.tools {
out.push(Value::String(lua.create_string(name)?));
out.push(era_bool(*have));
}
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// 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.
g.set(
"GetTradeskillRepeatCount",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(
model.trade_skill.as_ref().map_or(0, |t| t.repeat_count),
))
})?,
)?;
// 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).
g.set(
"SelectTradeSkill",
lua.create_function(|lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
select(&mut model, index);
Ok(())
})?,
)?;
g.set(
"GetTradeSkillSelectionIndex",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(selected_visible_index(&model)))
})?,
)?;
// 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.
g.set(
"DoTradeSkill",
lua.create_function(|lua, (index, count): (usize, Option<i64>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
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 (byte-VERIFIED —
// wow-re `tradeskill` TU-D, `DoTradeSkill 0x500280`).
let n = (count.unwrap_or(1).max(1) as u32).min(avail.max(1));
model.trade_skill_dos.push((spell_id, n));
}
Ok(())
})?,
)?;
// CloseTradeSkill() — client-side close (no packet, vanilla): flag it so the app clears its
// local state.
g.set(
"CloseTradeSkill",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.trade_skill_close = true;
Ok(())
})?,
)?;
// 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.
g.set(
"GetTradeSkillSubClassFilter",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(t) = model.trade_skill.as_ref() else {
return Ok(era_bool(false));
};
let groups = build_groups(&t.recipes);
Ok(match index.checked_sub(1) {
None => era_bool(
!groups
.iter()
.any(|g| model.trade_skill_subclass_hidden.contains(&g.key)),
),
Some(n) => era_bool(
groups
.get(n)
.is_some_and(|g| !model.trade_skill_subclass_hidden.contains(&g.key)),
),
})
})?,
)?;
g.set(
"SetTradeSkillSubClassFilter",
lua.create_function(
|lua, (index, on, exclusive): (usize, Option<i64>, Option<i64>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(t) = model.trade_skill.as_ref() else {
return Ok(());
};
let keys: Vec<(u32, u32)> = build_groups(&t.recipes)
.into_iter()
.map(|g| g.key)
.collect();
let on = on.unwrap_or(1) != 0;
let exclusive = exclusive.unwrap_or(0) != 0;
match index.checked_sub(1) {
None => model.trade_skill_subclass_hidden.clear(),
Some(n) => {
let Some(&key) = keys.get(n) else {
return Ok(());
};
if on && exclusive {
model.trade_skill_subclass_hidden =
keys.iter().copied().filter(|&k| k != key).collect();
} else if on {
model.trade_skill_subclass_hidden.remove(&key);
} else {
model.trade_skill_subclass_hidden.insert(key);
}
}
}
model.trade_skill_touched = true;
Ok(())
},
)?,
)?;
// GetTradeSkillInvSlots() → the distinct slot words of the open window's products, ascending
// slot-bit order (the `0xbde058` bit walk — [`present_inv_slots`]).
g.set(
"GetTradeSkillInvSlots",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
for bit in present_inv_slots(&model) {
out.push(Value::String(lua.create_string(inv_slot_name(bit))?));
}
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
// (TU-G §4's enumeration; `GetTradeSkillInvSlots` returns exactly that order).
g.set(
"GetTradeSkillInvSlotFilter",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.trade_skill.is_none() {
return Ok(era_bool(false));
}
let bits = present_inv_slots(&model);
Ok(match index.checked_sub(1) {
// The "all shown?" probe: (present & mask) == present (TU-G §4, `0x4fffd0`).
None => era_bool(
bits.iter()
.all(|&b| model.trade_skill_invslot_mask & (1 << b) != 0),
),
Some(n) => era_bool(
bits.get(n)
.is_some_and(|&b| model.trade_skill_invslot_mask & (1 << b) != 0),
),
})
})?,
)?;
g.set(
"SetTradeSkillInvSlotFilter",
lua.create_function(
|lua, (index, on, exclusive): (usize, Option<i64>, Option<i64>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.trade_skill.is_none() {
return Ok(());
}
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 (TU-G §4, the recovered `0x4fd730` args): all →
// 0xffffffff · off → old & ~(1<<b) · exclusive → 1<<b · add → old | (1<<b).
match index.checked_sub(1) {
None => model.trade_skill_invslot_mask = u32::MAX,
Some(n) => {
let Some(&bit) = bits.get(n) else {
return Ok(());
};
if on && exclusive {
model.trade_skill_invslot_mask = 1 << bit;
} else if on {
model.trade_skill_invslot_mask |= 1 << bit;
} else {
model.trade_skill_invslot_mask &= !(1 << bit);
}
}
}
model.trade_skill_touched = true;
Ok(())
},
)?,
)?;
Ok(())
}