druids get their relic slot, and the loot window is the reference's own

- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference
- the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil
- the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them
- the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:00:40 +07:00
co-authored by Claude Fable 5.1
parent d017ca1cde
commit ed524ab184
59 changed files with 2084 additions and 1231 deletions
@@ -587,6 +587,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
}
})?,
)?;
// UnitHasRelicSlot(unit) → **the number 1, or nil** (`0x519e50`). Whether this unit's
// INVSLOT 17 holds a Libram/Totem/Idol instead of a bow.
//
// The whole body is: typemask bit 4 (PLAYER) → `UNIT_FIELD_BYTES_0` byte 1 (the class) →
// bounds against `ds:0xc0def8` → `ds:0xc0def4`[class] → `[row+0x40]`, `ChrClasses.dbc` field
// 16. There is **no `cmp` against a class id anywhere in the function** — 1.12 is entirely
// data-driven here, and `benilla_formats::ChrClasses` is that data. The app resolves it and
// hands us the bit, exactly as it does for `class_file`.
//
// **This shipped absent for a long time, on a claim that was simply false** — that the relic
// slot post-dates 1.12 and `UnitHasRelicSlot` is a later-era verb. The base `dbc.MPQ` copy of
// `ChrClasses.dbc` really does have no field 16; the `patch.MPQ` copy the client actually
// reads has it, set for Paladin, Shaman and Druid. Decision 1785. Stock `PaperDollFrame.lua`
// calls this **unconditionally** at l.429 and l.580 — so while it was missing, the character
// sheet raised `attempt to call global 'UnitHasRelicSlot' (a nil value)` for *every* class,
// not just the three.
//
// No `"player"` fast path (unlike `UnitClass 0x5183b0`, which has one), so this answers for
// any token — `UnitHasRelicSlot("target")` on an enemy druid is 1, and the stock inspect path
// at `PaperDollFrame.lua:429` depends on exactly that.
//
// The truthy leg is the NUMBER 1, never a boolean; the false leg is nil, never `false`.
g.set(
"UnitHasRelicSlot",
lua.create_function(|lua, token: Option<String>| {
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
Ok(if has { Value::Integer(1) } else { Value::Nil })
})?,
)?;
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it); nil when the
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
g.set(
+12
View File
@@ -163,6 +163,18 @@ pub struct UnitState {
/// the ref's stat-tooltip lookups key on (`getglobal(strupper(class).."_STAT_TOOLTIP")` —
/// the ref uppercases it again defensively; we store it already uppercase).
pub class_file: Option<String>,
/// This unit's INVSLOT 17 is a **relic** slot rather than a ranged-weapon slot —
/// `UnitHasRelicSlot`'s whole answer (`0x519e50`).
///
/// Resolved app-side off `ChrClasses.dbc` field 16, for the same reason [`Self::class_file`]
/// is resolved app-side: it is a static per-class column of a table this crate cannot read.
/// The reference gates on TYPEMASK_PLAYER before it looks at the class byte, so the feed sets
/// this only for a player — a creature, a pet and an unstreamed unit all leave it `false`,
/// which is the nil leg.
///
/// True for exactly Paladin, Shaman and Druid in 1.12 (Libram, Totem, Idol). It was believed
/// here for a long time that no 1.12 class had one; see decision 1785 for how that survived.
pub has_relic_slot: bool,
/// The unit's sex on the `UnitSex` scale: `2` male, `3` female (`1` = neuter/unknown). `0`
/// (the unfilled default) reports as nil — the API's "can't tell", like [`Self::reaction`].
/// The app maps `UNIT_FIELD_BYTES_0` byte 2 (0 male / 1 female) onto this shape.
@@ -1291,3 +1291,56 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
// A bad token still raises through the shared resolver — shape C with a shape-A tail.
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
}
/// `UnitHasRelicSlot` — the number 1 or nil, per token, never a boolean.
///
/// This shipped **absent** for months on the belief that the relic slot post-dates 1.12, which is
/// false (decision 1785). Stock `PaperDollFrame.lua` calls it unconditionally at l.429 and l.580,
/// so while it was missing the character sheet raised `attempt to call global` for every class —
/// which is why the nil-global case is asserted here too, not just the answer.
#[test]
fn unit_has_relic_slot_answers_one_or_nil() {
let mut s = UiScript::new().unwrap();
let mut druid = player();
druid.class = Some("Druid".into());
druid.class_file = Some("DRUID".into());
druid.has_relic_slot = true;
s.set_unit("player", Some(druid));
let mut warrior = player();
warrior.has_relic_slot = false;
s.set_unit("target", Some(warrior));
// The truthy leg is the NUMBER 1 (`lua_pushnumber`, `0x519ec8`) — a caller comparing it to 1
// is stock idiom, and a boolean would silently fail that.
assert_eq!(
s.eval::<String>(r#"return tostring(UnitHasRelicSlot("player"))"#)
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("player"))"#)
.unwrap(),
"number"
);
// The false leg is nil, never `false` (`lua_pushnil`, `0x519edb`).
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("target"))"#)
.unwrap(),
"nil"
);
// No `"player"` fast path in the reference, so every token is answered the same way — the
// stock inspect path at `PaperDollFrame.lua:429` passes the inspected unit, not "player".
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("party1"))"#)
.unwrap(),
"nil"
);
// And it EXISTS — the regression that actually bit. A nil global here takes the whole
// character sheet down for every class, not just the three that answer 1.
assert_eq!(
s.eval::<String>("return type(UnitHasRelicSlot)").unwrap(),
"function"
);
}