Files
benilla-octo/crates/benilla-ui/src/script/tradeskill/api.rs
T
gromandClaude Opus 5 754d577eaf ui: a trade-skill list mutator signals inside its own call
`ExpandTradeSkillSubClass`, `CollapseTradeSkillSubClass`,
`SetTradeSkillSubClassFilter` and `SetTradeSkillInvSlotFilter` fire
`TRADE_SKILL_UPDATE` from inside the call now, as the one helper all four
tail-call in the reference does (`0x4fd710`, `0x4fd730`, `0x4fd750`;
`reference/1.12-verb-events.tsv`). It used to be a `trade_skill_touched` flag
drained by `ui_tradeskill::drain_trade_skill` and answered the following frame.

Inline is load-bearing. These four verbs are called *from* a
`TRADE_SKILL_UPDATE` handler: a profession library reads the recipe list by
unfolding every header, walking it and folding back what was folded. The signal
is therefore re-entrant by construction, and every addon that does it carries
its own guard — LibCraftingProfessions has `trade_skill_scan_in_progress`, ATSW
an `atsw_scans < 2` counter. Those guards are written against an inline signal
and only an inline signal lands inside the window they cover.

Deferred to the next frame it landed after the guard had cleared: the library
rescanned, the rescan called the verb, the verb re-armed the flag, and the two
kept each other alive at one fire a frame for as long as the window was open.
grom's 2026-10-21 session shows it directly — `[ui-events]` reporting
`TRADE_SKILL_UPDATE · 1.01 fires/f · ~900 call(s)` across 37 consecutive
windows, three unbroken minutes, at 0.58-0.84 ms a frame in handlers over four
addons rebuilding their lists for nothing. It is the 118-second and 61-second
stretches at 55-70 fps in a session whose in-play median was 86.

Two tests: each mutator's fire is read with its own `run` still on the stack, so
a deferred signal fails; and a library-shaped in-progress guard turns one real
update into exactly one scan, with nothing owed to a later tick.

The bench's `[events]` block also fires the five non-quest events the live
session named. They cost ~0.2-0.5 ms there against 12-72 ms live, because this
harness has no NPC in front of it, no trainer list and no open map — recorded as
a gap, not an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-21 14:47:57 -06:00

499 lines
21 KiB
Rust

//! The tradeskill Lua globals over [`super::view`]'s visible rows and filters.
use mlua::{Lua, MultiValue, Value};
use crate::script::binding_abi::{self, number_arg};
use crate::script::item_stats::item_link;
use crate::script::Model;
use super::view::{
build_groups, first_recipe_index, inv_slot_token, 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,
})
}
/// `TRADE_SKILL_UPDATE`, dispatched **now**, as the one helper all four list mutators tail-call
/// does (`0x4fd710`, `0x4fd730`, `0x4fd750` -> `reference/1.12-verb-events.tsv`).
///
/// Inline is the whole point, not a detail. These four verbs are called *from* a
/// `TRADE_SKILL_UPDATE` handler — a profession library reads the list by expanding every header,
/// walking it and folding back what was folded — so the signal is re-entrant by construction, and
/// every addon that does it carries its own guard against that: an in-progress flag, or a scan
/// counter. Those guards are written against an inline signal, and only an inline signal lands
/// inside the window they cover.
///
/// Queued for the next frame instead, the signal arrives after the guard has cleared, the library
/// scans again, its scan calls these verbs again, and the two sides keep each other alive at one
/// fire a frame for as long as the window is open. grom's 2026-10-21 session had exactly that:
/// `[ui-events]` reporting `TRADE_SKILL_UPDATE · 1.01 fires/f` across 37 consecutive windows —
/// three unbroken minutes — at 0.58-0.84 ms a frame in handlers over ~900 calls a window, four
/// addons rebuilding their recipe lists every frame for nothing. It is the 118-second and
/// 61-second stretches at 55-70 fps in a session whose median was 86.
fn signal_trade_skill_update(lua: &Lua) {
crate::script::tick::fire_event_into(lua, "TRADE_SKILL_UPDATE", Vec::new());
}
/// Register the tradeskill globals.
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetTradeSkillLine(): ("UNKNOWN", 0, 0) with no window open, as the reference answers.
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)),
]))
})?,
)?;
// GetNumTradeSkills(): the visible rows, headers included.
g.set(
"GetNumTradeSkills",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(num_rows(&model) as i64)
})?,
)?;
// 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| {
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`): 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)| {
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, "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| {
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),
binding_abi::flag(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,
]))
}
}
})?,
)?;
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 group names in order, unfiltered (`0x4ffb60`).
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))
})?,
)?;
// Expand/CollapseTradeSkillSubClass(i): the group whose header is visible row i; 0 is all.
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);
}
signal_trade_skill_update(lua);
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);
}
signal_trade_skill_update(lua);
Ok(())
})?,
)?;
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()))
})?,
)?;
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): nil when ready, which the stock Lua tests for truthiness
// (Blizzard_TradeSkillUI.lua:215).
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,
},
)
})?,
)?;
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): 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)| {
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): alternating (name, has) pairs, which the stock Lua feeds to
// `BuildColoredListString` (Blizzard_TradeSkillUI.lua:274).
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(binding_abi::flag(*have));
}
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// GetTradeskillRepeatCount(): the lowercase "s" is the 1.12 name.
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 and GetTradeSkillSelectionIndex take and answer visible indices.
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): 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>)| {
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 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));
}
Ok(())
})?,
)?;
// CloseTradeSkill(): client-side only, no packet; the app clears its 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 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 inline (`0x4fd710`, see
// [`signal_trade_skill_update`]), 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| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(t) = model.trade_skill.as_ref() else {
return Ok(binding_abi::flag(false));
};
let groups = build_groups(&t.recipes);
Ok(match index.checked_sub(1) {
None => binding_abi::flag(
!groups
.iter()
.any(|g| model.trade_skill_subclass_hidden.contains(&g.key)),
),
Some(n) => binding_abi::flag(
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);
}
}
}
}
signal_trade_skill_update(lua);
Ok(())
},
)?,
)?;
// 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();
// 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))
.unwrap_or_default();
out.push(Value::String(lua.create_string(word)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// 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| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.trade_skill.is_none() {
return Ok(binding_abi::flag(false));
}
let bits = present_inv_slots(&model);
Ok(match index.checked_sub(1) {
// Everything shown: (present & mask) == present (`0x4fffd0`).
None => binding_abi::flag(
bits.iter()
.all(|&b| model.trade_skill_invslot_mask & (1 << b) != 0),
),
Some(n) => binding_abi::flag(
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 mask each path writes, as `0x4fd730` is called.
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);
}
}
}
}
signal_trade_skill_update(lua);
Ok(())
},
)?,
)?;
Ok(())
}