Files
benilla-octo/crates/benilla-ui/src/script/item_stats.rs
T
Sam SungandClaude Opus 5.5 256e331582 benilla-ui: unused globals and queues removed, one true/nil helper, three tests that test their claim (#305)
Code health in `benilla-ui`, found while its comments were rewritten. No behaviour a player can see changes.

- **Unused globals and queues removed.**
  - The Rust `ChatFrame_OpenChat` and `ChatFrame_SendTell` queued text that nothing drained. The stock `ChatFrame.lua` defines both globals and replaces them when it loads, as the reference's FrameXML does.
  - `BenillaGetItemStats` had no Lua caller. Its test of the item cache's miss, ask and push now runs through `GetItemInfo`, which shares that path.
  - `UiScript::queue_social_request` had no caller.
- **One true/nil helper.** Seven modules each carried their own copy of `binding_abi::flag`, the reference's `1`-or-nil boolean. They call it now.
- **Three tests test their claim.**
  - `click_focuses_regardless_of_autofocus_…` built both boxes with autoFocus on. One is now off.
  - `level_shift_saturates_at_zero` never saturated. Its child now drops past 0.
  - The layout profiler's `plan` bucket was never set, so it always printed 0. It is gone.
- **Smaller fixes.** An empty `if buyback {}` branch is gone, and a doc comment no longer sits on a `use` line instead of on `install_justify`.

Checked with `cargo clippy -p benilla-ui --all-targets -D warnings` and `cargo test -p benilla-ui` (1513 passed) before the land gates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:26:43 +07:00

861 lines
34 KiB
Rust

//! The shared item-template store every item tooltip renders through, as the reference's one
//! renderer (`0x52b650`, behind 8 of the 9 `Set*Item` bindings) reads one template cache.
//!
//! The app pushes each template as it lands; a read of an unknown id records an ask that the app
//! turns into `CMSG_ITEM_QUERY`, the reference's uncached-item early-out. The app resolves every
//! DBC string, as the engine reads no DBCs.
use std::collections::HashMap;
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// An item template's tooltip view: the `SMSG_ITEM_QUERY_SINGLE_RESPONSE` fields the tooltip reads,
/// plus app-resolved display strings.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ItemTemplateView {
pub name: String,
pub quality: u32,
pub class: u32,
pub subclass: u32,
pub inventory_type: u32,
/// `GetItemInfo`'s `itemType`, the `ItemClass.dbc` name (`0x48e070`); `None` answers `""`.
pub item_type: Option<String>,
/// `GetItemInfo`'s `itemSubType`, "One-Handed Swords": `ItemSubClass.dbc`'s VerboseName
/// (`+0x4c`), or its DisplayName (`+0x28`) when that is empty (`0x48e311`).
pub item_sub_type: Option<String>,
/// The tooltip's type cell and the bag line's `CONTAINER_SLOTS` name: `ItemSubClass.dbc`'s
/// DisplayName alone (`+0x28`, off the `0xc0db90` row cache), the singular "Sword".
pub sub_class_display: Option<String>,
/// The alternate subclass whose proficiency also permits use: `ItemSubClass.dbc`'s
/// prerequisite, or its postrequisite when the prerequisite is -1. A weapon with only the
/// alternate's bit reds the slot cell, not the type cell.
pub proficiency_alt: Option<u32>,
/// `ItemSubClass.dbc` displayFlags bit 0: the type cell never prints (rings, trinkets, …).
pub hide_subclass: bool,
/// Template flags; `0x2` prints "Conjured Item".
pub flags: u32,
/// 1 binds on pickup, 2 on equip, 3 on use, 4 and 5 quest item.
pub bonding: u32,
/// `MaxCount`: 1 prints "Unique", N > 1 "Unique (N)"; not the stack size.
pub max_count: u32,
/// `Stackable`, `GetItemInfo`'s `itemStackCount` (`+0x60`, `0x48e28b`); `MaxCount` is `+0x5c`.
pub stackable: u32,
pub start_quest: u32,
pub container_slots: u32,
/// `(ItemModType, value)` in wire order, the `ITEM_MOD_*` lines.
pub stats: Vec<(u32, i32)>,
/// `(min, max, school)` in wire order.
pub damages: Vec<(f32, f32, u32)>,
pub delay_ms: u32,
pub armor: u32,
pub block: u32,
/// Holy to Arcane.
pub resistances: [i32; 6],
pub max_durability: u32,
/// "Requires Level N", printed only for N > 1 (`0x52d2cf`).
pub required_level: u32,
/// Class and race masks: `<= 0` prints no line, and a line missing the player's bit is red.
pub allowable_class: i32,
pub allowable_race: i32,
/// `SkillLine.dbc` id, with its rank and app-resolved name; a `None` name prints no line.
pub required_skill: u32,
pub required_skill_rank: u32,
pub required_skill_name: Option<String>,
pub required_spell: u32,
pub required_spell_name: Option<String>,
/// No tooltip line in 1.12; only the usable gate (`0x5ea930`) reads it.
pub required_honor_rank: u32,
/// Usable gate only; nonzero always fails, as the `PVP_MEDALS` bits it tests are never set.
pub required_city_rank: u32,
/// "Requires <Faction> - <Standing>", app-resolved; the raw faction and rank drive its red.
pub required_rep_line: Option<String>,
pub required_rep_faction: u32,
pub required_rep_rank: u32,
/// Green trigger-spell lines `(trigger, spell id, text)` in wire order: 0 and 5 "Use:",
/// 1 "Equip:", 2 "Chance on hit:", 6 a taught spell (no line, only the "Already known" red).
pub spell_triggers: Vec<(u32, u32, String)>,
pub lock_id: u32,
/// "N Charge(s)" for the first spell slot past the builder's gate (`0x52db51`: 0 and -1
/// print nothing, else the absolute value); 0 prints no line.
pub charges: i32,
pub description: String,
pub page_text: u32,
/// Copper: the merchant money row, or `ITEM_UNSELLABLE` when 0 in a sell context.
pub sell_price: u32,
pub item_set: u32,
/// `GetItemInfo`'s `itemTexture`, the `Interface\Icons\…` path through `ItemDisplayInfo.dbc`
/// (`0x48e2dd`); `None` for a display row with no icon.
pub icon: Option<String>,
/// `RandomProperty` (template `+0x1b8`): the item can roll a suffix, so a template tooltip
/// prints `<Random enchantment>` (`0x52cc33`).
pub random_property: u32,
}
/// The player state the tooltip's red lines and the usable gate compare against.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlayerReqState {
pub level: u32,
/// 1 warrior … 11 druid; the `allowable_class` bit is `1 << (id-1)`.
pub class_id: u32,
/// 1 human … 8 troll; the `allowable_race` bit is `1 << (id-1)`.
pub race_id: u32,
/// SkillLine id → value plus permanent bonus, what the gates compare.
pub skills: HashMap<u32, u32>,
/// Item class → `SMSG_SET_PROFICIENCY` subclass mask (`0xc4d4a0[class]`); a class with no
/// entry never reds.
pub proficiency: HashMap<u32, u32>,
/// Faction id → reputation rank, 0 hated … 7 exalted.
pub rep_ranks: HashMap<u32, u8>,
/// Whether the spellbook holds a `SPELL_EFFECT_DUAL_WIELD` (40) spell, the client's
/// `0xc4d770` (read at `0x5eab70`); without it an off-hand weapon reds its slot cell.
pub can_dual_wield: bool,
/// The highest lifetime honor rank (`PLAYER_FIELD_BYTES` byte 3).
pub honor_rank: u8,
}
/// [`item_usable`] by item entry: entry 0 and a template not yet answered are usable, as the
/// merchant getter skips an uncached record (`0x4fb298`).
pub(super) fn item_usable_by_id(model: &super::Model, item_id: u32) -> bool {
item_id == 0
|| model.item_templates.get(&item_id).is_none_or(|v| {
item_usable(v, &model.player_req, |id| {
model.spellbook.slots.iter().any(|s| s.spell_id == id)
})
})
}
/// The client's item-usable predicate `0x5ea930`, whose answer the merchant getters push as
/// `isUsable` (`0x4fb2a3`, `0x4fb4f7`), its legs in the reference's order. Proficiency tests the
/// item's own subclass bit, with no alternate walk. A nonzero city rank always fails: it tests
/// `PLAYER_FIELD_PVP_MEDALS`, which is never written. Reputation compares ranks, equivalent to the
/// reference's standing threshold (`0x4d6370`, table `0x80928c`); an unknown faction is rank 0. An
/// unpushed state (level 0) declines to judge, answering usable.
pub fn item_usable(
v: &ItemTemplateView,
req: &PlayerReqState,
knows_spell: impl Fn(u32) -> bool,
) -> bool {
if req.level == 0 {
return true;
}
if v.required_level > req.level {
return false;
}
if req.class_id == 0 || v.allowable_class as u32 & (1 << (req.class_id - 1)) == 0 {
return false;
}
if req.race_id == 0 || v.allowable_race as u32 & (1 << (req.race_id - 1)) == 0 {
return false;
}
if let Some(&mask) = req.proficiency.get(&v.class) {
if mask & (1 << v.subclass) == 0 {
return false;
}
}
if v.required_skill != 0 {
match req.skills.get(&v.required_skill) {
None => return false,
Some(&val) => {
if val < v.required_skill_rank {
return false;
}
}
}
}
if v.required_spell != 0 && !knows_spell(v.required_spell) {
return false;
}
if v.required_honor_rank != 0 && u32::from(req.honor_rank) < v.required_honor_rank {
return false;
}
if v.required_city_rank != 0 {
return false;
}
if v.required_rep_faction != 0 {
let rank = req
.rep_ranks
.get(&v.required_rep_faction)
.copied()
.unwrap_or(0);
if u32::from(rank) < v.required_rep_rank {
return false;
}
}
true
}
/// An item set's tooltip block (`0x854b1c`), app-resolved from `ItemSet.dbc`: member names from
/// the template cache and the bonuses' substituted text. The engine counts the equipped members.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ItemSetView {
pub name: String,
/// `(item id, name)` per member in DBC order; `None` while its template is uncached.
pub members: Vec<(u32, Option<String>)>,
/// `(required equipped count, bonus text)` in DBC slot order; the renderer sorts by count, as
/// the builder's qsort does (`0x52e5c0`).
pub bonuses: Vec<(u32, String)>,
/// The set's "Requires <skill> (N)"; 0 prints no line.
pub required_skill: u32,
pub required_skill_rank: u32,
pub required_skill_name: Option<String>,
}
impl super::UiScript {
/// Stores or replaces an item's template view, clearing its ask.
pub fn set_item_template(&mut self, item_id: u32, view: ItemTemplateView) {
let mut model = self.model_mut();
model.item_stat_asks.remove(&item_id);
model.item_templates.insert(item_id, view);
}
/// Asks for the templates of `ids` the store lacks, for the trade-skill and craft lists,
/// whose link verbs never query: the reference has them cached before the list shows.
pub fn ask_item_templates(&mut self, ids: impl IntoIterator<Item = u32>) {
let mut model = self.model_mut();
for id in ids {
if id != 0 && !model.item_templates.contains_key(&id) {
model.item_stat_asks.insert(id);
}
}
}
/// Drains the ids read or asked for that the store did not hold.
pub fn take_item_stat_asks(&mut self) -> Vec<u32> {
self.model_mut().item_stat_asks.drain().collect()
}
/// Stores or replaces a set's view, re-pushed as member names resolve.
pub fn set_item_set(&mut self, set_id: u32, view: ItemSetView) {
let mut model = self.model_mut();
model.item_set_asks.remove(&set_id);
model.item_sets.insert(set_id, view);
}
/// Drains the set ids the renderer asked for that the store did not hold.
pub fn take_item_set_asks(&mut self) -> Vec<u32> {
self.model_mut().item_set_asks.drain().collect()
}
/// Pushes the whole `ItemRandomProperties` table once, at load: a clicked tooltip cannot
/// repaint on a late answer.
pub fn set_random_properties(
&mut self,
rows: std::collections::HashMap<u32, super::RandomPropertyView>,
) {
self.model_mut().random_properties = rows;
}
/// Pushes the player state the red lines compare against.
pub fn set_player_req_state(&mut self, state: PlayerReqState) {
self.model_mut().player_req = state;
}
}
/// The 1.12 item-quality palette: the ARGB literals the static init `0x5291d0` writes to the BGRA
/// array `0xc0d3c8`, and the escape strings at `0x854124`. `GetItemQualityColor` (`0x48dfb0`)
/// answers each channel times 1/255.
const QUALITY_COLORS: [(u8, u8, u8, &str); 7] = [
(0x9d, 0x9d, 0x9d, "|cff9d9d9d"), // 0 Poor
(0xff, 0xff, 0xff, "|cffffffff"), // 1 Common
(0x1e, 0xff, 0x00, "|cff1eff00"), // 2 Uncommon
(0x00, 0x70, 0xdd, "|cff0070dd"), // 3 Rare
(0xa3, 0x35, 0xee, "|cffa335ee"), // 4 Epic
(0xff, 0x80, 0x00, "|cffff8000"), // 5 Legendary
(0xe6, 0xcc, 0x80, "|cffe6cc80"), // 6 Artifact
];
/// The client's item-link builder `0x52adb0` as the trade-skill and craft link verbs call it:
/// `|c<rrggbb>|Hitem:<id>:0:0:0|h[<name>]|h|r`. A quality of 7 or more selects index 1, white,
/// by `0x52ad90`'s unsigned `cmp ecx,7; jb`.
pub(super) fn item_link(item_id: u32, name: &str, quality: u32) -> String {
let hex = QUALITY_COLORS
.get(quality as usize)
.unwrap_or(&QUALITY_COLORS[1])
.3;
format!("{hex}|Hitem:{item_id}:0:0:0|h[{name}]|h|r")
}
/// `InventoryType` → `GetItemInfo`'s `itemEquipLoc`, the reference's 30-pointer table at
/// `0x83ddb0` (`0x48e29b`): slots 0 and 29 are the shared `""` (`0x882748`), not nil, and four
/// tokens (`AMMO`, `THROWN`, `RANGEDRIGHT`, `QUIVER`) have no `GlobalStrings.lua` entry.
fn equip_loc_token(inventory_type: u32) -> &'static str {
const TOKENS: [&str; 29] = [
"",
"INVTYPE_HEAD",
"INVTYPE_NECK",
"INVTYPE_SHOULDER",
"INVTYPE_BODY",
"INVTYPE_CHEST",
"INVTYPE_WAIST",
"INVTYPE_LEGS",
"INVTYPE_FEET",
"INVTYPE_WRIST",
"INVTYPE_HAND",
"INVTYPE_FINGER",
"INVTYPE_TRINKET",
"INVTYPE_WEAPON",
"INVTYPE_SHIELD",
"INVTYPE_RANGED",
"INVTYPE_CLOAK",
"INVTYPE_2HWEAPON",
"INVTYPE_BAG",
"INVTYPE_TABARD",
"INVTYPE_ROBE",
"INVTYPE_WEAPONMAINHAND",
"INVTYPE_WEAPONOFFHAND",
"INVTYPE_HOLDABLE",
"INVTYPE_AMMO",
"INVTYPE_THROWN",
"INVTYPE_RANGEDRIGHT",
"INVTYPE_QUIVER",
"INVTYPE_RELIC",
];
// Deviation: bounds-checked where the reference indexes unguarded, so none reads past it.
TOKENS.get(inventory_type as usize).copied().unwrap_or("")
}
/// The reference's `SStrToInt` (`0x64ac60`) over an `item:` field: an optional `-`, then digits up
/// to the first non-digit, with no whitespace skip and no `+`. Deviation: an overflow saturates at
/// `u32::MAX` where the reference wraps, because only a script can overflow it.
fn reference_atoi(s: &str) -> i64 {
let b = s.as_bytes();
let (neg, mut i) = match b.first() {
Some(b'-') => (true, 1),
_ => (false, 0),
};
let mut n: i64 = 0;
while let Some(&c) = b.get(i) {
if !c.is_ascii_digit() {
break;
}
n = n
.saturating_mul(10)
.saturating_add(i64::from(c - b'0'))
.min(i64::from(u32::MAX));
i += 1;
}
if neg {
-n
} else {
n
}
}
/// `GetItemInfo`'s argument as the reference parses it (`0x48e0a3`-`0x48e16d`): a number, or a
/// string `lua_isnumber` (`0x6f34d0`) coerces, is the id alone; another string (`0x6f3510`) must
/// pass `strnicmp(s, "item:", 5)` (`0x64a4c0`) for its four fields, else it is id 0, so a name or
/// a hyperlink finds nothing; anything else raises the usage error (`0x842d24`, via `0x6f4940`).
fn parse_item_arg(v: &Value) -> mlua::Result<(i64, u32, u32, u32)> {
let as_number = match v {
Value::Integer(i) => Some(*i as f64),
Value::Number(n) => Some(*n),
Value::String(s) => s.to_str().ok().and_then(|s| s.trim().parse::<f64>().ok()),
_ => None,
};
if let Some(n) = as_number {
return Ok((n as i64, 0, 0, 0));
}
// An `item:` string; any other string is id 0.
let Value::String(s) = v else {
return Err(mlua::Error::RuntimeError(
"Usage: GetItemInfo(itemID|\"itemlink\")".into(),
));
};
let s = s
.to_str()
.map_err(|_| mlua::Error::RuntimeError("Usage: GetItemInfo(itemID|\"itemlink\")".into()))?;
let Some(rest) = s
.get(..5)
.filter(|p| p.eq_ignore_ascii_case("item:"))
.map(|_| &s[5..])
else {
return Ok((0, 0, 0, 0));
};
let mut fields = rest.splitn(4, ':').map(reference_atoi);
let id = fields.next().unwrap_or(0);
let mut next = || u32::try_from(fields.next().unwrap_or(0)).unwrap_or(0);
Ok((id, next(), next(), next()))
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetItemQualityColor(quality) → r, g, b, escapeString (`0x48dfb0`). 7 and up, or a negative
// quality, answer Common (`0x52ad70`/`0x52ad90`: an unsigned `cmp ecx,7; jb`), so
// `ITEM_QUALITY_COLORS[-1]` is white, the colour of an uncached loot row (`0x4c2435`).
lua.globals().set(
"GetItemQualityColor",
lua.create_function(|lua, quality: i64| {
// A 32-bit int (`__ftol`), compared unsigned.
let i = match quality as i32 as u32 {
q if q >= 7 => 1,
q => q as usize,
};
let (r, g, b, hex) = QUALITY_COLORS[i];
Ok(mlua::MultiValue::from_vec(vec![
Value::Number(f64::from(r) / 255.0),
Value::Number(f64::from(g) / 255.0),
Value::Number(f64::from(b) / 255.0),
Value::String(lua.create_string(hex)?),
]))
})?,
)?;
// GetItemInfo(itemID | "item:id:enchant:randomProperty:suffix") → itemName, itemLink,
// itemQuality, itemMinLevel, itemType, itemSubType, itemStackCount, itemEquipLoc, itemTexture:
// nine values (`0x48e070` ends `mov eax,0x9`); later clients insert `itemLevel` at 4. The name
// is the bare template name, where the reference appends the random suffix (`0x5d8b00`); the
// link is the item string (`0x48e1c9`, literal `0x842d4c`), not a hyperlink; the level is the
// required one (`[record+0x3c]`; `ItemLevel` at `+0x38` is never pushed). An uncached
// template answers no values and records the ask, as the reference's lookup (`0x55ba30`)
// queries and returns null, sending the binding to `xor eax,eax; ret`.
lua.globals().set(
"GetItemInfo",
lua.create_function(|lua, arg: Value| {
let (id, enchant, random_property, suffix) = parse_item_arg(&arg)?;
let Ok(item_id) = u32::try_from(id) else {
return Ok(MultiValue::new()); // no record holds a negative or past-u32 id
};
let view = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let v = model.item_templates.get(&item_id).cloned();
if v.is_none() && item_id != 0 {
model.item_stat_asks.insert(item_id);
}
v
};
let Some(v) = view else {
return Ok(MultiValue::new());
};
let str_or_empty = |s: &Option<String>| s.clone().unwrap_or_default();
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&v.name)?),
Value::String(lua.create_string(format!(
"item:{item_id}:{enchant}:{random_property}:{suffix}"
))?),
Value::Integer(i64::from(v.quality)),
Value::Integer(i64::from(v.required_level)),
Value::String(lua.create_string(str_or_empty(&v.item_type))?),
Value::String(lua.create_string(str_or_empty(&v.item_sub_type))?),
Value::Integer(i64::from(v.stackable)),
Value::String(lua.create_string(equip_loc_token(v.inventory_type))?),
// Deviation: nil for a display row with no icon, where the reference answers the
// unloadable `Interface\Icons\`, because `SetTexture` shows no icon for either.
match &v.icon {
Some(icon) => Value::String(lua.create_string(icon)?),
None => Value::Nil,
},
]))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{item_usable, ItemTemplateView, PlayerReqState};
use crate::script::UiScript;
#[test]
fn item_usable_mirrors_the_gate_legs() {
let base_item = ItemTemplateView {
allowable_class: -1,
allowable_race: -1,
..Default::default()
};
let base_req = PlayerReqState {
level: 4,
class_id: 1, // warrior
race_id: 2, // orc
..Default::default()
};
let knows_none = |_: u32| false;
assert!(item_usable(&base_item, &base_req, knows_none));
// Level 5 required: level 4 fails, 5 passes (`jg`).
let mut v = base_item.clone();
v.required_level = 5;
assert!(!item_usable(&v, &base_req, knows_none));
let mut req = base_req.clone();
req.level = 5;
assert!(item_usable(&v, &req, knows_none));
let mut v = base_item.clone();
v.allowable_class = 1 << 3; // rogue-only (class 4)
assert!(!item_usable(&v, &base_req, knows_none));
let mut v = base_item.clone();
v.allowable_race = 1 << 0; // human-only
assert!(!item_usable(&v, &base_req, knows_none));
// No alternate walk: a 2H axe fails with only the 1H bit set.
let mut v = base_item.clone();
(v.class, v.subclass) = (2, 1); // Two-Handed Axe
let mut req = base_req.clone();
req.proficiency.insert(2, 1 << 0); // knows One-Handed Axes only
assert!(!item_usable(&v, &req, knows_none));
req.proficiency.insert(2, 1 << 1);
assert!(item_usable(&v, &req, knows_none));
// No mask for the class: the leg never fires.
let mut v = base_item.clone();
(v.class, v.subclass) = (0, 0);
assert!(item_usable(&v, &base_req, knows_none));
// An unknown skill fails even at rank 0.
let mut v = base_item.clone();
v.required_skill = 164; // Blacksmithing
assert!(!item_usable(&v, &base_req, knows_none));
let mut req = base_req.clone();
req.skills.insert(164, 0);
assert!(item_usable(&v, &req, knows_none));
v.required_skill_rank = 100;
assert!(!item_usable(&v, &req, knows_none));
req.skills.insert(164, 100);
assert!(item_usable(&v, &req, knows_none));
let mut v = base_item.clone();
v.required_spell = 9787; // Weaponsmith
assert!(!item_usable(&v, &base_req, knows_none));
assert!(item_usable(&v, &base_req, |id| id == 9787));
let mut v = base_item.clone();
v.required_honor_rank = 3;
assert!(!item_usable(&v, &base_req, knows_none));
let mut req = base_req.clone();
req.honor_rank = 3;
assert!(item_usable(&v, &req, knows_none));
let mut v = base_item.clone();
v.required_city_rank = 1;
assert!(!item_usable(&v, &base_req, knows_none));
// An unknown faction is rank 0.
let mut v = base_item.clone();
(v.required_rep_faction, v.required_rep_rank) = (87, 5);
assert!(!item_usable(&v, &base_req, knows_none));
let mut req = base_req.clone();
req.rep_ranks.insert(87, 5);
assert!(item_usable(&v, &req, knows_none));
let mut v = base_item.clone();
(v.required_rep_faction, v.required_rep_rank) = (87, 0);
assert!(item_usable(&v, &base_req, knows_none));
// An unpushed state (level 0) declines to judge.
let mut v = base_item.clone();
v.required_level = 60;
assert!(item_usable(&v, &PlayerReqState::default(), knows_none));
}
#[test]
fn miss_records_ask_and_push_serves_the_stats() {
let mut s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return GetItemInfo(25) == nil").unwrap());
assert_eq!(s.take_item_stat_asks(), vec![25]);
s.set_item_template(
25,
ItemTemplateView {
name: "Worn Shortsword".into(),
quality: 1,
inventory_type: 21,
class: 2,
subclass: 7,
damages: vec![(1.0, 3.0, 0)],
delay_ms: 1900,
..Default::default()
},
);
let (name, quality): (String, i64) = s
.eval("local n, _, q = GetItemInfo(25) return n, q")
.unwrap();
assert_eq!((name.as_str(), quality), ("Worn Shortsword", 1));
assert!(s.take_item_stat_asks().is_empty(), "push cleared the ask");
// Id 0 records no ask.
assert!(s.eval::<bool>("return GetItemInfo(0) == nil").unwrap());
assert!(s.take_item_stat_asks().is_empty());
}
}
#[cfg(test)]
mod get_item_info_tests {
use super::{equip_loc_token, reference_atoi, ItemTemplateView};
use crate::script::UiScript;
/// Worn Shortsword, item 25, as vmangos `item_template` holds it (item level 2).
fn worn_shortsword() -> ItemTemplateView {
ItemTemplateView {
name: "Worn Shortsword".into(),
quality: 1,
class: 2,
subclass: 7,
inventory_type: 21,
item_type: Some("Weapon".into()),
// VerboseName, not the tooltip cell's "Sword" (`0x48e311`).
item_sub_type: Some("One-Handed Swords".into()),
// Required level 1 against item level 2 makes position 4 falsifiable.
required_level: 1,
stackable: 1,
max_count: 0,
icon: Some("Interface\\Icons\\INV_Sword_04".into()),
..Default::default()
}
}
/// The arity catches a later client's shape, which inserts `itemLevel` at position 4.
#[test]
fn get_item_info_returns_the_1_12_nine_value_shape() {
let mut s = UiScript::new().unwrap();
s.set_item_template(25, worn_shortsword());
assert_eq!(
s.arity("GetItemInfo(25)").unwrap(),
9,
"1.12 returns nine values (`mov eax,0x9` at 0x48e303) — a tenth means the modern shape"
);
let (name, link, quality, min_level, ty, sub_ty, stack, equip, texture): (
String,
String,
i64,
i64,
String,
String,
i64,
String,
Option<String>,
) = s
.eval("local a,b,c,d,e,f,g,h,i = GetItemInfo(25) return a,b,c,d,e,f,g,h,i")
.unwrap();
assert_eq!(name, "Worn Shortsword");
// The item string (`0x842d4c`); a number argument leaves the other fields 0.
assert_eq!(link, "item:25:0:0:0");
assert_eq!(quality, 1);
assert_eq!(min_level, 1);
assert_eq!(ty, "Weapon");
assert_eq!(sub_ty, "One-Handed Swords");
assert_eq!(stack, 1);
assert_eq!(equip, "INVTYPE_WEAPONMAINHAND");
assert_eq!(texture.as_deref(), Some("Interface\\Icons\\INV_Sword_04"));
}
/// Ashbringer, item 13262: required level 60, item level 76 in vmangos `item_template`.
#[test]
fn position_four_is_the_required_level_not_the_item_level() {
let mut s = UiScript::new().unwrap();
s.set_item_template(
13262,
ItemTemplateView {
name: "Ashbringer".into(),
quality: 5,
class: 2,
subclass: 8,
inventory_type: 17,
item_type: Some("Weapon".into()),
item_sub_type: Some("Two-Handed Swords".into()),
required_level: 60, // item level 76 is never returned
max_count: 1,
stackable: 1,
..Default::default()
},
);
assert_eq!(
s.eval::<i64>("local _, _, _, minLevel = GetItemInfo(13262) return minLevel")
.unwrap(),
60,
"position 4 is RequiredLevel ([record+0x3c]); ItemLevel lives at +0x38 and is not pushed"
);
assert_eq!(
s.eval::<String>("local _, _, _, _, itemType = GetItemInfo(13262) return itemType")
.unwrap(),
"Weapon",
"position 5 is itemType — a modern shape would put a NUMBER (itemMinLevel) here"
);
assert_eq!(
s.eval::<String>(
"local _, _, _, _, _, _, _, equipLoc = GetItemInfo(13262) return equipLoc"
)
.unwrap(),
"INVTYPE_2HWEAPON"
);
}
/// Linen Cloth, item 2589: `max_count 0, stackable 20` in vmangos `item_template`.
#[test]
fn position_seven_is_the_stack_size_not_the_unique_cap() {
let mut s = UiScript::new().unwrap();
s.set_item_template(
2589,
ItemTemplateView {
name: "Linen Cloth".into(),
quality: 1,
class: 7,
subclass: 0,
item_type: Some("Trade Goods".into()),
item_sub_type: Some("Trade Goods".into()),
max_count: 0,
stackable: 20,
..Default::default()
},
);
let (stack, equip): (i64, String) = s
.eval("local _,_,_,_,_,_,g,h = GetItemInfo(2589) return g,h")
.unwrap();
assert_eq!(stack, 20, "Stackable (+0x60), never MaxCount (+0x5c)");
// InventoryType 0 answers "", not nil.
assert_eq!(equip, "");
}
#[test]
fn an_uncached_id_returns_nothing_and_records_the_ask() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.arity("GetItemInfo(2589)").unwrap(),
0,
"an unseen template returns NO values"
);
assert!(s.eval::<bool>("return (GetItemInfo(2589)) == nil").unwrap());
assert_eq!(s.take_item_stat_asks(), vec![2589], "the ask was recorded");
s.set_item_template(
2589,
ItemTemplateView {
name: "Linen Cloth".into(),
stackable: 20,
..Default::default()
},
);
assert_eq!(
s.eval::<String>("return (GetItemInfo(2589))").unwrap(),
"Linen Cloth",
"the app's push answers the next read"
);
assert!(s.take_item_stat_asks().is_empty(), "the push cleared it");
// Id 0 and a negative id record no ask.
assert_eq!(s.arity("GetItemInfo(0)").unwrap(), 0);
assert_eq!(s.arity("GetItemInfo(-5)").unwrap(), 0);
assert!(s.take_item_stat_asks().is_empty());
}
#[test]
fn the_argument_forms_are_the_references_own() {
let mut s = UiScript::new().unwrap();
s.set_item_template(25, worn_shortsword());
for arg in ["25", "\"25\"", "25.7"] {
assert_eq!(
s.eval::<String>(&format!("return (GetItemInfo({arg}))"))
.unwrap(),
"Worn Shortsword",
"argument {arg}"
);
}
// An item string's other fields echo into return 2 verbatim.
let (name, link): (String, String) = s
.eval("local a,b = GetItemInfo(\"item:25:2564:7:0\") return a,b")
.unwrap();
assert_eq!(name, "Worn Shortsword");
assert_eq!(link, "item:25:2564:7:0");
// A case-insensitive prefix, and a short string still parses.
assert_eq!(
s.eval::<String>("local _, link = GetItemInfo(\"ITEM:25\") return link")
.unwrap(),
"item:25:0:0:0"
);
// A bare name and a full hyperlink are id 0: no values and no ask.
for arg in [
"\"Worn Shortsword\"",
"\"|cffffffff|Hitem:25:0:0:0|h[Worn Shortsword]|h|r\"",
"\"\"",
] {
assert_eq!(
s.arity(&format!("GetItemInfo({arg})")).unwrap(),
0,
"argument {arg} resolves to item id 0"
);
}
assert!(s.take_item_stat_asks().is_empty());
// Neither a number nor a string raises the usage error (`0x842d24`).
let err = s
.eval::<mlua::Value>("return GetItemInfo(nil)")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: GetItemInfo(itemID|\"itemlink\")"),
"expected the reference's usage error, got {err}"
);
}
#[test]
fn the_equip_loc_tokens_are_the_binarys_table() {
assert_eq!(equip_loc_token(0), "", "index 0 is the shared \"\"");
assert_eq!(equip_loc_token(1), "INVTYPE_HEAD");
assert_eq!(equip_loc_token(3), "INVTYPE_SHOULDER", "singular");
assert_eq!(equip_loc_token(9), "INVTYPE_WRIST", "singular");
assert_eq!(equip_loc_token(10), "INVTYPE_HAND", "singular");
assert_eq!(equip_loc_token(17), "INVTYPE_2HWEAPON");
assert_eq!(equip_loc_token(20), "INVTYPE_ROBE");
// The four with no `GlobalStrings.lua` entry.
assert_eq!(equip_loc_token(24), "INVTYPE_AMMO");
assert_eq!(equip_loc_token(25), "INVTYPE_THROWN");
assert_eq!(equip_loc_token(26), "INVTYPE_RANGEDRIGHT");
assert_eq!(equip_loc_token(27), "INVTYPE_QUIVER");
assert_eq!(equip_loc_token(28), "INVTYPE_RELIC", "the last real token");
assert_eq!(equip_loc_token(29), "", "the array's trailing \"\"");
assert_eq!(equip_loc_token(9999), "", "past the array");
}
#[test]
fn the_field_parser_is_the_references_atoi() {
assert_eq!(reference_atoi("2589"), 2589);
assert_eq!(reference_atoi("-5"), -5);
assert_eq!(reference_atoi(""), 0);
assert_eq!(reference_atoi("abc"), 0);
assert_eq!(reference_atoi(" 12"), 0, "no leading-whitespace skip");
assert_eq!(reference_atoi("+12"), 0, "no unary plus");
assert_eq!(reference_atoi("12abc"), 12, "stops at the first non-digit");
}
}
#[cfg(test)]
mod quality_color_tests {
use crate::script::UiScript;
#[test]
fn get_item_quality_color_is_the_references_own_table() {
let s = UiScript::new().unwrap();
let hex = |q: i64| {
s.eval::<String>(&format!(
"local r,g,b,h = GetItemQualityColor({q}) return h"
))
.unwrap()
};
for (q, want) in [
(0, "|cff9d9d9d"),
(1, "|cffffffff"),
(2, "|cff1eff00"),
(3, "|cff0070dd"),
(4, "|cffa335ee"),
(5, "|cffff8000"),
(6, "|cffe6cc80"),
] {
assert_eq!(hex(q), want, "quality {q}");
}
// The floats are byte / 255: Epic's red is 0xa3.
let r = s
.eval::<f64>("local r = GetItemQualityColor(4) return r")
.unwrap();
assert!((r - 163.0 / 255.0).abs() < 1e-9, "epic red was {r}");
// 7 and up answer Common (`0x52ad70`).
assert_eq!(hex(7), "|cffffffff");
assert_eq!(hex(99), "|cffffffff");
// The compare is unsigned, so a negative answers Common too.
assert_eq!(hex(-1), "|cffffffff");
assert_eq!(hex(-99), "|cffffffff");
}
}