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

1140 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The **shared item-template store** — one `item id → ItemTemplateView` table every item hover
//! renders through (decision 0274 P1). In the real client every item tooltip is the same C++
//! renderer (`0x52b650`, behind 8 of the 9 `Set*Item` bindings — wow-re
//! `ui/scratch/tooltip-money.md`) over the same item-template cache; benilla mirrors that with
//! one engine store the app feeds from its ask-once `ITEM_QUERY` template cache, and one engine
//! renderer ([`super::tooltip_item`]).
//!
//! Fill flow: the app **pushes** every item template the moment it lands in its cache
//! ([`super::UiScript::set_item_template`]) — arrival-driven, so a first hover of an item whose
//! name is already on screen always hits. A renderer read of an id the app never resolved
//! additionally records the id ([`super::UiScript::take_item_stat_asks`] drains them), which
//! makes the app send `CMSG_ITEM_QUERY` and push when the answer arrives — the real client's
//! uncached-item early-out (cleared tooltip + query; the hover's re-enter loop repaints on
//! arrival).
//!
//! The view carries the template's tooltip-relevant fields plus the strings only the app can
//! resolve (skill/faction names from the DBC catalogs, trigger-spell display text) — the engine
//! renders lines, it never reads DBCs.
use std::collections::HashMap;
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// An item template's tooltip view (decision 0274 P1) — the fields the line law consumes, in
/// wire terms (vmangos `SMSG_ITEM_QUERY_SINGLE_RESPONSE`), plus app-resolved display strings.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ItemTemplateView {
/// The name line (quality-colored).
pub name: String,
pub quality: u32,
/// Item class/subclass (the slot line's right column) + the equip slot (its left).
pub class: u32,
pub subclass: u32,
pub inventory_type: u32,
/// `GetItemInfo`'s `itemType`: what [`Self::class`] is **called** — "Weapon", "Container",
/// "Trade Goods" — app-resolved from `ItemClass.dbc` (byte-verified: `0x48e070` pushes
/// `[classRow + 4*locale + 0xc]`). `None` for a class with no row, which the reference
/// renders as the empty string.
pub item_type: Option<String>,
/// `GetItemInfo`'s `itemSubType`: what `(class, subclass)` is **called** — app-resolved from
/// `ItemSubClass.dbc`, **VerboseName first with a DisplayName fallback**
/// ([`benilla_formats::ItemSubClassCatalog::name`]), which is the binding's own two-step at
/// `0x48e311`: read `[row + 4*locale + 0x4c]` and use it unless its first byte is 0, else
/// `[row + 4*locale + 0x28]`.
///
/// **This is a different spelling from the tooltip's type cell**, which reads `+0x28`
/// (DisplayName) only — a one-handed sword is "One-Handed Swords" here and "Sword" there.
pub item_sub_type: Option<String>,
/// The alternate subclass whose proficiency also permits use (ItemSubClass.dbc
/// prerequisite/postrequisite, app-resolved with the builder's sentinel walk: prerequisite
/// wins, postrequisite only when prerequisite is −1). A weapon missing its own mask bit but
/// holding the alternate's reds the SLOT cell instead of the type cell.
pub proficiency_alt: Option<u32>,
/// ItemSubClass displayFlags bit 0 (app-resolved): the type cell never prints — the
/// "Miscellaneous" family (rings, trinkets, shirts, consumables, recipes).
pub hide_subclass: bool,
/// Template flags — bit `0x2` = conjured ("Conjured Item").
pub flags: u32,
/// Bonding: 1 = binds on pickup, 2 = on equip, 3 = on use, 4/5 = quest item.
pub bonding: u32,
/// `MaxCount` — 1 = "Unique", N > 1 = "Unique (N)", 0 = no line. **Not the stack size**; see
/// [`Self::stackable`].
pub max_count: u32,
/// `Stackable` — the biggest stack one slot can hold (1 = doesn't stack). `GetItemInfo`'s
/// `itemStackCount`, read from the cache record's `+0x60` at `0x48e28b` — the dword *after*
/// `MaxCount` at `+0x5c`. Linen Cloth is `maxcount 0, stackable 20`, so the two are not
/// interchangeable in either direction.
pub stackable: u32,
/// Nonzero = "This Item Begins a Quest".
pub start_quest: u32,
/// Container size — "N Slot Bag".
pub container_slots: u32,
/// Stat mods `(type, value)` in wire order (types: 0 mana, 1 health, 3 agi, 4 str, 5 int,
/// 6 spi, 7 stam — the `ITEM_MOD_*` GlobalStrings family).
pub stats: Vec<(u32, i32)>,
/// Damage blocks `(min, max, school)` in wire order; school 0 physical, 1..6 =
/// Holy/Fire/Nature/Frost/Shadow/Arcane.
pub damages: Vec<(f32, f32, u32)>,
pub delay_ms: u32,
pub armor: u32,
pub block: u32,
/// Holy..Arcane (armor is its own field), the "+N X Resistance" lines.
pub resistances: [i32; 6],
/// "Durability N / N" (a template hover shows full).
pub max_durability: u32,
/// "Requires Level N" — printed only for N > 1 (the real builder's `0x52d2cf` gate); red
/// when the player is lower.
pub required_level: u32,
/// Class/race masks; `<= 0` = everyone (no line). Red when the player's bit is absent.
pub allowable_class: i32,
pub allowable_race: i32,
/// Skill requirement: the SkillLine id + rank, with the display name app-resolved
/// (`SkillLine.dbc`). `required_skill_name = None` = no skill line.
pub required_skill: u32,
pub required_skill_rank: u32,
pub required_skill_name: Option<String>,
/// Spell requirement (nonzero = "Requires <name>") — red when the spellbook doesn't know it.
pub required_spell: u32,
pub required_spell_name: Option<String>,
/// `RequiredHonorRank` — no tooltip line in 1.12, but the item-usable gate (`0x5ea930`)
/// compares it against the player's highest honor rank; the merchant list reds on it.
pub required_honor_rank: u32,
/// `RequiredCityRank` — usable-gate only, like the honor rank. Vanilla data ships no nonzero
/// value and vmangos never writes the `PVP_MEDALS` bits the client would test, so a nonzero
/// requirement can only fail (see [`item_usable`]).
pub required_city_rank: u32,
/// Reputation requirement, app-resolved to "Requires <Faction> - <Standing>"; the raw
/// faction id + rank ride along for the red check against [`PlayerReqState::rep_ranks`].
pub required_rep_line: Option<String>,
pub required_rep_faction: u32,
pub required_rep_rank: u32,
/// Trigger-spell lines `(trigger, spell id, display text)` in wire order: trigger 0/5 =
/// "Use:", 1 = "Equip:", 2 = "Chance on hit:", 6 = a taught spell (the "Already known" red
/// check, no green line). The text is app-resolved (the spell's name in P1; its substituted
/// description in P2) — green lines.
pub spell_triggers: Vec<(u32, u32, String)>,
/// `LockID` — nonzero prints the red "Locked" line (the key-item sub-line joins with the
/// Lock.dbc resolve, the GO-locks follow-up).
pub lock_id: u32,
/// "N Charge(s)" — the app-resolved count for the first spell slot that survives the real
/// builder's charge gate (`0x52db51`: a slot whose value is 0 or the `-1` consume-on-use
/// sentinel prints nothing; else `abs`). 0 = no line.
pub charges: i32,
/// The yellow quoted flavor text (wrapped).
pub description: String,
/// Nonzero = "<Right Click to Read>" (green).
pub page_text: u32,
/// Copper — the merchant-open money row (the engine fires `OnTooltipAddMoney`), and
/// `ITEM_UNSELLABLE` when 0 in a sell context.
pub sell_price: u32,
/// `itemset` — nonzero renders the SET block ([`ItemSetView`], asked once by set id).
pub item_set: u32,
/// The inventory icon as a ready `Interface\Icons\…` path — app-resolved through
/// `ItemDisplayInfo.dbc` off the template's `display_info_id`, the same lookup the bag slots
/// use. `GetItemInfo`'s `itemTexture`; the reference builds the identical string at
/// `0x48e2dd` (`"%s%s%s"` over the icon directory, `"\\"`, and the row's icon name).
/// `None` on the ~26% of display rows that carry no icon.
pub icon: Option<String>,
/// `RandomProperty` (template `+0x1b8`) — the item CAN roll a "… of the Bear" suffix. Its one
/// consumer is the enchant family's third arm: with no instance to read a roll from, the
/// tooltip prints the `<Random enchantment>` placeholder instead of any per-slot line (wow-re
/// §1-ENCHANT §E5). Decision 0920.
pub random_property: u32,
}
/// The player state the red-line law compares against (decision 0274 P1): pushed by the app
/// whenever it changes. Level and class also ride the `"player"` unit feed; this carries the
/// pieces that don't (the class/race IDS as mask bits, and the skill ranks).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlayerReqState {
pub level: u32,
/// Class id (1 warrior … 11 druid) — the `allowable_class` mask bit is `1 << (id-1)`.
pub class_id: u32,
/// Race id (1 human … 8 troll) — the `allowable_race` mask bit is `1 << (id-1)`.
pub race_id: u32,
/// SkillLine id → current rank, for "Requires <skill> (N)" checks.
pub skills: HashMap<u32, u32>,
/// Item class (2 weapons / 4 armor) → allowed-subclass bitmask (`SMSG_SET_PROFICIENCY`,
/// the client's `0xc4d4a0[class]` store). The slot-line proficiency red: a class WITH an
/// entry here reds when the item's `1 << subclass` bit is absent; a class with no entry
/// (consumables etc.) never reds.
pub proficiency: HashMap<u32, u32>,
/// Faction id → the player's reputation rank (0 hated … 7 exalted; DBC base + wire
/// standing, app-ranked) — the "Requires <Faction> - <Standing>" red check.
pub rep_ranks: HashMap<u32, u8>,
/// Whether the spellbook holds an effect-40 (SPELL_EFFECT_DUAL_WIELD) spell — the client's
/// `0xc4d770` global (stored on learn, cleared on unlearn; reader `0x5eab70`). An off-hand
/// weapon (InventoryType 22) reds its SLOT cell without it.
pub can_dual_wield: bool,
/// The player's **highest lifetime honor rank** (`PLAYER_FIELD_BYTES` byte 3) — the
/// usable-gate's `RequiredHonorRank` comparand.
pub honor_rank: u8,
}
/// [`item_usable`] for an item the caller knows only by **entry** — the shape every window seam
/// wants, since a row carries an id and the engine holds the templates.
///
/// Two conventions are folded in here rather than re-derived per caller: entry `0` (a row with no
/// item at all) is usable, and a template the app has not answered for yet is usable, which is the
/// getter's own null-record skip. It was four identical private copies across the merchant, mail,
/// trade and auction seams before it was one — three had the `item_id == 0` leg and the fourth
/// reached the same answer through `is_none_or`, which is exactly the kind of agreement that stops
/// being reliable the moment someone edits one of them.
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(player; itemCacheRecord, &err)` — byte-read from
/// wow-re's `ui/scratch/disasm-full.txt`. Both merchant getters call it (`GetMerchantItemInfo`
/// `0x4fb2a3`, `GetBuybackItemInfo` `0x4fb4f7`) and push `1`/`nil` as `isUsable`; the FrameXML
/// reds the row on `nil`. The legs, in the binary's order:
///
/// 1. `requiredLevel > player level` → unusable.
/// 2. class mask: `allowableClass & 1<<(classId−1)` clear → unusable (−1 = every bit set).
/// 3. race mask: same test against `allowableRace`.
/// 4. proficiency: a mask exists for the item class AND the item's **own** subclass bit is clear
/// → unusable. NO ItemSubClass alternate walk here — the alternate only chooses which tooltip
/// CELL reds (0297); the usable gate is the raw bit.
/// 5. `requiredSkill`: unknown skill → unusable; known → `value + permBonus ≥ requiredSkillRank`.
/// 6. `requiredSpell`: not in the spellbook → unusable.
/// 7. `requiredHonorRank`: player's highest honor rank (`PLAYER_FIELD_BYTES` byte 3) short →
/// unusable.
/// 8. `requiredCityRank`: tests `PLAYER_FIELD_PVP_MEDALS & 1<<(rank−1)` — vanilla never writes
/// the medals field and ships no city-rank items, so a nonzero requirement always fails;
/// mirrored as the constant result rather than plumbing a dead field.
/// 9. reputation: player standing ≥ the required rank's threshold (`0x4d6370` +
/// the `0x80928c` threshold table) — equivalently rank ≥ requiredRepRank, the tooltip red's
/// exact compare; an unknown faction counts as rank 0.
///
/// A template the cache hasn't answered yet is USABLE (the getter skips the call on a null
/// record — `0x4fb298`); the engine analog is an unpushed [`PlayerReqState`] (level 0, a state
/// the real client can't reach), which also declines to judge.
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 view (the §22 SET block), app-resolved: the ItemSet.dbc row with
/// member item NAMES joined from the template cache (a `None` name = the member's template is
/// still in flight — its line waits; the app re-pushes as answers land) and the threshold
/// bonuses' TEXT ($-substituted spell descriptions). The engine supplies the live half: the
/// owned/equipped counts off its own inventory slots.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ItemSetView {
pub name: String,
/// `(item id, resolved name)` per member, DBC order.
pub members: Vec<(u32, Option<String>)>,
/// `(required equipped count, bonus text)` per bonus, in the DBC's stored slot order —
/// the renderer sorts threshold-ascending at print time, like the builder's qsort
/// (`0x52e5c0`; The Gladiator ships 3,2,5,4 and prints 2,3,4,5).
pub bonuses: Vec<(u32, String)>,
/// The set-level skill requirement ("Requires <skill> (N)"), app-named; 0/None = no line.
pub required_skill: u32,
pub required_skill_rank: u32,
pub required_skill_name: Option<String>,
}
impl super::UiScript {
/// Store (or replace) an item's template view — the app's push half of the ask-once flow.
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);
}
/// Drain the ids the renderer asked for that the store didn't have.
/// Ask the app for the templates of `ids` the store does not hold yet — the trade-skill and
/// craft feeds' pre-ask (1973): the client has every product's and reagent's template cached
/// by the time its list shows, and the link verbs never query, so the feeds ask on the app's
/// behalf when the list lands and the verbs read the answers.
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);
}
}
}
pub fn take_item_stat_asks(&mut self) -> Vec<u32> {
self.model_mut().item_stat_asks.drain().collect()
}
/// Store (or replace) a set's view — the SET block's push half (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);
}
/// Drain the set ids the renderer asked for that the store didn't have.
pub fn take_item_set_asks(&mut self) -> Vec<u32> {
self.model_mut().item_set_asks.drain().collect()
}
/// Push the **whole** random-suffix table (`ItemRandomProperties` id → its resolved view) —
/// once, at load. Not an ask-once store: see [`super::Model::random_properties`] for why the
/// roll table is pushed whole where the template store is asked for.
pub fn set_random_properties(
&mut self,
rows: std::collections::HashMap<u32, super::RandomPropertyView>,
) {
self.model_mut().random_properties = rows;
}
/// Push the red-line law's player state (level/class/race/skills). Cheap to call on change.
pub fn set_player_req_state(&mut self, state: PlayerReqState) {
self.model_mut().player_req = state;
}
}
/// Register the shared item-stats global (the P0 Lua stat-head read — still the merchant
/// sell-cursor's source and the compat surface while call sites finish moving to the engine
/// renderer; the tooltip itself no longer routes through it).
/// The 1.12 item-quality palette, **byte-verified** — wow-5875-re RF-0055
/// (`scratch/rf55-quality-color-table.md`): the seven ARGB literals the static init `0x5291d0`
/// writes into the BGRA array at `0xc0d3c8`, and the parallel escape strings at `0x854124`.
///
/// `Script::GetItemQualityColor 0x48dfb0` reads `[quality*4 + 0xc0d3c8]`, takes `[+2]=R`,
/// `[+1]=G`, `[+0]=B`, multiplies each by `1/255`, and returns them with the escape string.
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
/// (wow-re `tradeskill/scratch/tradeskill-craft-item-links.md`, 1973): `|c<rrggbb>|Hitem:<id>:0:0:0|h[<name>]|h|r`
/// — the three tokens literal zeros at those call sites, the colour from `0x52ad90`'s table where
/// `cmp ecx,7; jb` is UNSIGNED, so a quality of 7 or more, or a negative one, selects index 1
/// (white): a fixed fallback, not a clamp. The `""`-suffix arm is dead, so every link ends `|h|r`.
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` → the `INVTYPE_*` token `GetItemInfo` returns as `itemEquipLoc`.
///
/// **The reference's own table, read out of the binary**: `0x48e29b` does
/// `mov eax,[record+0x2c]` (the inventory type) then `mov edx,[eax*4 + 0x83ddb0]` and pushes the
/// string — an unguarded index into a 30-pointer array at `0x83ddb0`. Its entries, decoded from
/// the PE image, are exactly the 28 tokens below at 1..=28, with index **0 and 29 both pointing at
/// the shared empty string `0x882748`**. So a non-equippable item answers `""`, not nil: an addon
/// doing `getglobal(itemEquipLoc)` gets nil either way, but one doing `itemEquipLoc == ""` — or
/// concatenating it — sees what the real client shows.
///
/// The spellings are the client's, and four of them (`AMMO`, `THROWN`, `RANGEDRIGHT`, `QUIVER`)
/// have **no matching GlobalString** in the shipped 1.12 `GlobalStrings.lua`, which defines only
/// 24 `INVTYPE_*` entries. That asymmetry is the reference's, not ours: the binding hands back a
/// token the FrameXML cannot localize. Note also `SHOULDER`/`WRIST`/`HAND` are singular here where
/// our numeric equip-slot map's comments say `SHOULDERS`/`WRISTS`/`HANDS`.
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",
];
// The reference does not bounds-check (the value always comes off its own cache record); we
// do, because a Lua-reachable path must not depend on that. 29 is the array's own trailing "".
TOKENS.get(inventory_type as usize).copied().unwrap_or("")
}
/// The reference's `atoi` (`0x64ac60`), which is what parses each field of an `item:` string:
/// an optional leading `-`, then decimal digits, stopping at the first byte that is not one.
/// **No leading-whitespace skip, no `+`, no `0x`** — and no overflow check either, but a saturating
/// accumulate is the sane read of a value that can only come from a script.
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, resolved to the four `item:` fields — **the reference's parser**
/// (`0x48e0a3`-`0x48e16d`), which is narrower than every later client's and narrower than the
/// binding's own usage string suggests:
///
/// 1. `lua_isnumber` (`0x6f34d0` — LUA_TNUMBER **or a string Lua coerces to one**) → truncate to
/// an int and that is the whole answer; enchant/random-property/suffix stay 0. This is the arm
/// `GetItemInfo(2589)` and `GetItemInfo(tostring(id))` both take.
/// 2. else `lua_isstring` (`0x6f3510`) → `strnicmp(s, "item:", 5)` (`0x64a4c0` → `0x414310`). On a
/// match, `atoi` the four colon-separated fields; **on a miss the id stays 0** and the caller
/// returns nothing.
/// 3. else raise `Usage: GetItemInfo(itemID|"itemlink")` — the literal at `0x842d24`, pushed
/// through `0x6f4940` (`luaL_where` + `lua_pushvfstring` + concat + `lua_error`: it raises).
///
/// **So a bare item NAME and a full `|cff…|Hitem:…|h[Name]|h|r` hyperlink both resolve to nothing**
/// on the real 1.12 client — neither begins with `item:`. The "itemlink" the usage string means is
/// the *item string*, which is also what the binding returns as its second value. Auctioneer builds
/// exactly that (`AucItemDB.lua:333`: `string.format("item:%s:%s:%s:0", …)`) before calling this.
fn parse_item_arg(v: &Value) -> mlua::Result<(i64, u32, u32, u32)> {
// Arm 1 — a number, or a string Lua's own coercion reads as one.
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));
}
// Arm 2 — a string. Anything that is not `item:`-prefixed leaves the id at 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` (decision 1199).
//
// A **C-registered** binding in the reference (`0x48dfb0`) and `function engine` in the
// captured `_G`, which is why it belongs here rather than in `assets/ui`: the shipped
// `UIParent.lua` *builds* `ITEM_QUALITY_COLORS` by calling it in a loop (`for i = -1, 6`), so
// the table is FrameXML's and the verb underneath is the engine's. 23 corpus addons call it,
// and three of our own XML files carry a private copy of the palette this replaces.
//
// **The clamp is the reference's, not a guess** — and it is ONE clamp, not two, because the
// comparison is UNSIGNED. `0x52ad70`/`0x52ad90` both do `cmp ecx,7` / `jb`, so anything at or
// above 7 selects index **1** (Common) — not 6, not an error — and a NEGATIVE quality takes the
// same branch, because as a `u32` it is at or above 7 too. `GetItemQualityColor(-1)` is
// therefore **white**, and so is `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s
// `for i = -1, 6` builds.
//
// This corrects 1199, which called the negative arm "the one place this binding is deliberately
// not bit-faithful … because the faithful answer is an out-of-bounds read" and answered Poor
// (grey). There is no out-of-bounds read: the unsigned compare catches it first. The value
// stopped being academic when the loot window went to the chain — `-1` is what
// `GetLootSlotInfo` answers for a row whose item template is not cached, and that row's text
// colour is this table's `-1` row (wow-re `system/ui/scratch/loot-slot-record.md` §10,
// decision 1805).
lua.globals().set(
"GetItemQualityColor",
lua.create_function(|lua, quality: i64| {
// The reference reads its argument as a 32-bit int (`ftol`) and then compares it
// unsigned, which is exactly this pair of casts.
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, and every one of them byte-verified** against the registered binding
// `0x48e070` (wow-5875-re `system/ui/ledger.tsv:891`), which ends `mov eax,0x9; ret`. The
// signature is the whole point of the verb (decision 1199): a later client inserts `itemLevel`
// at position 4 and pushes the required level to 5, and 36 corpus addons destructure this one
// positionally — `Informant/Informant.lua:268` reads all nine and stores position 4 as
// `['reqLevel']`, `Auctioneer/Database/AucItemDB.lua:288` as `useLevel`.
//
// Per value, with the instruction that pushes it:
//
// 1 `itemName` `0x5d8b00(buf, 0x400, itemId, randomProperty)` — the template name with the
// `ItemRandomProperties.dbc` suffix appended. **We push the bare template
// name**: the suffix table is the same stated gap `ui_items::item_link`
// already carries, one random-suffix arc, not per-call-site drift.
// 2 `itemLink` `0x48e1c9`: `SStrPrintf(buf, 0x400, "item:%d:%d:%d:%d", id, enchant,
// randomProperty, suffix)` — the literal at `0x842d4c`. In 1.12 this is the
// **item string**, NOT the coloured `|cff…|Hitem:…|h[Name]|h|r` hyperlink
// (that is `GetContainerItemLink`'s shape and stays there). Auctioneer's
// `getItemInfoFromBlizzard` names the return `itemString` and feeds it
// straight back in, which only works because both ends are this shape.
// 3 `itemQuality` `fild [record+0x1c]` — the same dword wow-re pinned through this function
// for the drag payload (`scratch/cursor-dragdrop-payload.md:223`).
// 4 `itemMinLevel` `fild [record+0x3c]` — the **required** level. `ItemLevel` lives one dword
// earlier at `+0x38` and is never pushed here. This is the trap.
// 5 `itemType` ItemClass.dbc's localized class name, or `""` (`0x48e236`).
// 6 `itemSubType` ItemSubClass.dbc, VerboseName then DisplayName (`0x48e311`), or `""`.
// 7 `itemStackCount` `fild [record+0x60]` — `Stackable`, not `MaxCount` (`+0x5c`).
// 8 `itemEquipLoc` `[invType*4 + 0x83ddb0]` — the `INVTYPE_*` token; `""` at index 0.
// 9 `itemTexture` `"%s%s%s"` over the icon dir, `"\\"` and the ItemDisplayInfo icon name.
//
// **A template the store has not seen yet returns nothing and records the ask**, exactly like
// `BenillaGetItemStats` — and exactly like the reference, whose cache lookup `0x55ba30` fires
// the query and returns null, taking the binding straight to `xor eax,eax; ret` (0 values).
// The first call comes back empty and the answer arrives later; we never block or fabricate.
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()); // negative / past u32: no record can hold it
};
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))?),
// The one value that can be genuinely absent rather than empty: a display row
// with no icon column. `nil` rather than the reference's `Interface\Icons\`,
// which is a path that cannot load — both read as "no icon" to `SetTexture`.
match &v.icon {
Some(icon) => Value::String(lua.create_string(icon)?),
None => Value::Nil,
},
]))
})?,
)?;
// BenillaGetItemStats(itemId) → name, quality, invType, class, subclass, dmgMin, dmgMax,
// dmgType, delayMs, armor, block, sellPrice — or nil (recording the ask).
lua.globals().set(
"BenillaGetItemStats",
lua.create_function(|lua, item_id: u32| {
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::from_vec(vec![Value::Nil]));
};
let (dmg_min, dmg_max, dmg_type) = v.damages.first().copied().unwrap_or_default();
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&v.name)?),
Value::Integer(i64::from(v.quality)),
Value::Integer(i64::from(v.inventory_type)),
Value::Integer(i64::from(v.class)),
Value::Integer(i64::from(v.subclass)),
Value::Number(f64::from(dmg_min)),
Value::Number(f64::from(dmg_max)),
Value::Integer(i64::from(dmg_type)),
Value::Integer(i64::from(v.delay_ms)),
Value::Integer(i64::from(v.armor)),
Value::Integer(i64::from(v.block)),
Value::Integer(i64::from(v.sell_price)),
]))
})?,
)
}
#[cfg(test)]
mod tests {
use super::{item_usable, ItemTemplateView, PlayerReqState};
use crate::script::UiScript;
/// Every leg of the `0x5ea930` gate, one at a time against a passing baseline.
#[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));
// 1 · level: required 5 vs level 4 fails; exactly 5 passes (jg, not jge).
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));
// 2/3 · class + race masks: the player's bit must be set; −1 has every bit.
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));
// 4 · proficiency: a mask for the item class with the subclass bit clear fails — and
// there is NO alternate walk here (a 2H axe reds even when the 1H bit is 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 at all (consumables): the leg never fires.
let mut v = base_item.clone();
(v.class, v.subclass) = (0, 0);
assert!(item_usable(&v, &base_req, knows_none));
// 5 · skill: unknown skill fails even at rank 0; known compares value ≥ rank.
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));
// 6 · spell: required and not in the spellbook fails.
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));
// 7 · honor rank: the player's highest rank must reach it.
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));
// 8 · city rank: no live data ever sets it, and the medals field it tests is never
// written — a nonzero requirement can only fail.
let mut v = base_item.clone();
v.required_city_rank = 1;
assert!(!item_usable(&v, &base_req, knows_none));
// 9 · reputation: rank below the requirement fails; at it, passes; an unknown faction
// is rank 0 (fails any nonzero requirement, passes a zero one).
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 player state (level 0 — unreachable in the real client) declines to judge.
let mut v = base_item.clone();
v.required_level = 60;
assert!(item_usable(&v, &PlayerReqState::default(), knows_none));
}
/// The ask-once loop end-to-end: a miss answers nil AND records the ask; the app's push makes
/// the next read answer the stat head; the push clears the pending ask.
#[test]
fn miss_records_ask_and_push_serves_the_stats() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return BenillaGetItemStats(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, inv): (String, i64, i64) =
s.eval("return BenillaGetItemStats(25)").unwrap();
assert_eq!((name.as_str(), quality, inv), ("Worn Shortsword", 1, 21));
assert!(s.take_item_stat_asks().is_empty(), "push cleared the ask");
// id 0 (an unresolved row) never records a junk ask.
assert!(s
.eval::<bool>("return BenillaGetItemStats(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 (25)**, with every field taken from the live vmangos `item_template`
/// row so the assertions below are about real data:
/// `class 2, subclass 7, quality 1, inventory_type 21, required_level 1, item_level 2,
/// max_count 0, stackable 1`. The two app-resolved names are what the shipped
/// `ItemClass.dbc` / `ItemSubClass.dbc` rows hold for `(2, 7)`.
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" — the binding's `0x48e311` two-step.
item_sub_type: Some("One-Handed Swords".into()),
// The pair that makes position 4 falsifiable: this item's REQUIRED level is 1 and its
// ITEM level is 2. Ashbringer (13262) is the loud version — required 60, item 76.
required_level: 1,
stackable: 1,
max_count: 0,
icon: Some("Interface\\Icons\\INV_Sword_04".into()),
..Default::default()
}
}
/// The whole signature, destructured in order exactly as `Informant/Informant.lua:268` and
/// `Auctioneer/Database/AucItemDB.lua:288` destructure it.
///
/// **The arity assertion is half the test.** A regression to a later client's shape inserts
/// `itemLevel` at position 4 and makes this ten values; `select('#', …)` is the only check
/// that notices, because every individual read still "works".
#[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.eval::<i64>("return select('#', 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");
// Return 2 is the ITEM STRING, not the coloured hyperlink (`"item:%d:%d:%d:%d"` at
// 0x842d4c). A number argument leaves the other three fields at 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"));
}
/// **Position 4 is the REQUIRED level, and position 5 is a string.** This is the whole reason
/// decision 1199 exists, one verb later: every client after 1.12 inserts `itemLevel` at 4 and
/// pushes `itemMinLevel` to 5, so a regression makes position 5 a *number*. Both halves are
/// asserted, against a template whose two levels genuinely differ (Ashbringer: required 60,
/// item level 76 — verified in the live 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 is 76 and is NEVER returned by 1.12
max_count: 1,
stackable: 1,
..Default::default()
},
);
assert_eq!(
s.eval::<i64>("return (select(4, GetItemInfo(13262)))")
.unwrap(),
60,
"position 4 is RequiredLevel ([record+0x3c]); ItemLevel lives at +0x38 and is not pushed"
);
assert_eq!(
s.eval::<String>("return (select(5, GetItemInfo(13262)))")
.unwrap(),
"Weapon",
"position 5 is itemType — a modern shape would put a NUMBER (itemMinLevel) here"
);
assert_eq!(
s.eval::<String>("return (select(8, GetItemInfo(13262)))")
.unwrap(),
"INVTYPE_2HWEAPON"
);
}
/// **Position 7 is `Stackable`, not `MaxCount`** — `[record+0x60]`, the dword *after*
/// `MaxCount` at `+0x5c`. The view carries both and they are not interchangeable: Linen Cloth
/// is `max_count 0, stackable 20` in the live `item_template`, so reading the wrong one gives
/// every reagent counter in the corpus a stack size of zero.
#[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 the EMPTY STRING, not nil — index 0 of the 0x83ddb0 table
// points at the shared "" at 0x882748.
assert_eq!(equip, "");
}
/// The ask-once loop, the reference's own uncached-item behaviour: the cache lookup
/// (`0x55ba30`) fires the query and returns null, and the binding falls to
/// `xor eax,eax; ret` — **no values at all**, not a nil.
#[test]
fn an_uncached_id_returns_nothing_and_records_the_ask() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', 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 are not askable — no junk query goes out for them.
assert_eq!(
s.eval::<i64>("return select('#', GetItemInfo(0))").unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return select('#', GetItemInfo(-5))")
.unwrap(),
0
);
assert!(s.take_item_stat_asks().is_empty());
}
/// **The argument forms, which are narrower than the documentation** (see `parse_item_arg`):
/// a number, a string Lua coerces to one, or an `item:`-prefixed *item string*. A bare item
/// NAME and a full `|cff…|Hitem:…` hyperlink both resolve to id 0 on the real client, because
/// `strnicmp(s, "item:", 5)` is the only string test there is.
#[test]
fn the_argument_forms_are_the_references_own() {
let mut s = UiScript::new().unwrap();
s.set_item_template(25, worn_shortsword());
// A number, and a string Lua's own coercion reads as one (`lua_isnumber`, 0x6f34d0) —
// `GetItemInfo(tostring(id))` is a real corpus call shape.
for arg in ["25", "\"25\"", "25.7"] {
assert_eq!(
s.eval::<String>(&format!("return (GetItemInfo({arg}))"))
.unwrap(),
"Worn Shortsword",
"argument {arg}"
);
}
// The item string — the form Auctioneer builds (`AucItemDB.lua:333`). Its enchant /
// random-property / suffix fields are echoed back into return 2 verbatim, which is what
// makes Auctioneer's feed-the-answer-back-in round trip.
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");
// Case-insensitive prefix, and a truncated string still parses (atoi stops at the NUL).
assert_eq!(
s.eval::<String>("return (select(2, GetItemInfo(\"ITEM:25\")))")
.unwrap(),
"item:25:0:0:0"
);
// A bare NAME and a full hyperlink: no values. Neither begins with "item:", so the id
// stays 0 and the record lookup fails — the real client answers nothing here too, and
// neither records an ask.
for arg in [
"\"Worn Shortsword\"",
"\"|cffffffff|Hitem:25:0:0:0|h[Worn Shortsword]|h|r\"",
"\"\"",
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', 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 binding's own usage error (`0x842d24`, pushed
// through the `luaL_error` shape at `0x6f4940`).
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}"
);
}
/// The `INVTYPE_*` table, transcribed from the 30-pointer array at `0x83ddb0`: both ends,
/// the four tokens the shipped `GlobalStrings.lua` never defines, and the empty-string slots.
#[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 GlobalString to localize them — the reference's own asymmetry.
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");
}
/// The reference's `atoi` (`0x64ac60`): no whitespace skip, no `+`, stop at the first
/// non-digit. The last two cases are why this is not `str::parse`.
#[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;
/// The seven colours, byte-verified against wow-5875-re RF-0055's own table — and the two
/// edges, which are where every reimplementation of this goes wrong.
///
/// The escape string matters as much as the floats: the reference returns it as the fourth
/// value and addons splice it straight into a link (`ITEM_QUALITY_COLORS[q].hex .. name`), so
/// a missing or differently-cased one shows as literal text in a chat line.
#[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`, not a rounded approximation — Epic's red is 0xa3/255.
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` clamps to COMMON (index 1), the accessor `0x52ad70`'s own rule — not to Artifact,
// and never to nil, because the caller concatenates the result.
assert_eq!(hex(7), "|cffffffff");
assert_eq!(hex(99), "|cffffffff");
// …and the compare is UNSIGNED, so every negative takes the SAME branch and reads Common.
// This is the row `UIParent.lua`'s `for i = -1, 6` builds, and the colour a loot row wears
// while its item template is uncached (`GetLootSlotInfo` answers -1 there). It read Poor
// until 1805; there is no out-of-bounds read to reproduce, the `jb` catches it first.
assert_eq!(hex(-1), "|cffffffff");
assert_eq!(hex(-99), "|cffffffff");
}
}