case-folded slots, a chat-type table, and a seated spellbook

- GetInventorySlotInfo folds case the way the client does, and gains the twelve bag-slot rows the shipped data really carries
- ChatTypeGroup exists for addons that register their own chat types at file scope — one missing table was taking five addons down
- GetChannelList answers in join order, shaped by the addons that consume it, and the harness's `--why` can now open any demand count
- the survey seats a spellbook, and the shapeshift bar's shelf art exists hidden, both exposing paths an empty fixture could never reach

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:56:25 +07:00
co-authored by Claude Fable 5
parent efd3c5e2b0
commit 4edc69e29e
9 changed files with 471 additions and 31 deletions
@@ -47,6 +47,36 @@
-- where the reference registers them here. That divergence is recorded in `slash.rs`.
SlashCmdList = {}
-- ── ChatTypeGroup / OtherMenuChatTypeGroups — declared, and deliberately EMPTY ────────
-- The reference builds `ChatTypeGroup` at ChatFrame.lua l.115-348: 68 entries mapping a
-- chat TYPE to the list of `CHAT_MSG_*` events that belong to it, which its
-- `ChatFrame_RegisterForMessages` walks to register events on a chat window.
--
-- Declared here for the same reason `SlashCmdList` above is: an addon REGISTERS ITS OWN
-- chat type at file scope, and a missing table is not a missing feature — it is
-- `attempt to index global 'ChatTypeGroup'` on the addon's very first chunk. CT_RaidAssist
-- does exactly that three lines apart (`CT_RaidAssist.lua:59` assigns
-- `ChatTypeGroup["CTRAID"]`, `:63` tinserts into `OtherMenuChatTypeGroups`), and it dies
-- there — taking `CT_RA_OnEvent` with it, which is why FOUR MORE corpus addons
-- (CT_RABossMods, FixCTGroups, FuBar_CTRaid, SmartRes) die on that function being nil.
-- One missing table, five addons.
--
-- **EMPTY, not populated, and that is the honest shape rather than a shortcut.** Two
-- reasons, in order of weight:
-- · The 68 entries are 233 lines of the reference's own Lua. We RUN the reference's
-- files where we can (1234) and write our own where we cannot; what we do not do is
-- paste its source into a repo that mirrors publicly.
-- · Nothing would read them. Measured across the 218-addon corpus with `grep -a`:
-- CT_RaidAssist is the ONLY addon that touches either table, and it only WRITES. Not
-- one addon reads a group out.
--
-- The divergence that leaves, stated rather than discovered: our chat frames do not
-- register through this table at all — `ui_chat` routes host-side, the same split
-- `SlashCmdList` records above. So an addon that adds a group gets no routing from it.
-- That is the `ChatFrame_OnEvent` architecture question, not this table's.
ChatTypeGroup = {}
OtherMenuChatTypeGroups = {}
-- ── ChatTypeInfo — the per-chat-type table addons read colors out of ──────────────────
-- Declared HERE because the 1.12 client declares it at the top of ChatFrame.lua, and it
-- is an ADDON-FACING table before it is ours: `local info = ChatTypeInfo["SAY"]` then
@@ -30,6 +30,28 @@
real job is the timeout/override/escape paths.
-->
<Script><![CDATA[
-- ── MAX_RAID_MEMBERS (ref RaidFrame.lua l.2) ─────────────────────────────────────────
-- A fact about the protocol, not about our implementation: a 1.12 raid is 40, and the
-- `raid1`..`raid40` token space is 40 wide whether or not we build a raid UI. Declared
-- here rather than in a RaidFrame.xml because we ship none; party and raid are the same
-- subsystem family, and this is the file that already owns the group surface.
--
-- **The loop it feeds already works.** `for i = 1, MAX_RAID_MEMBERS do UnitName("raid"..i)`
-- answers nil for every absent token, which is exactly right for a player not in a raid —
-- so the constant is not a promise of a raid UI, it is the bound the token space has.
--
-- Reached by following a `'for' limit must be a number` five addons shared:
-- `CT_RaidAssist.lua:3283` is `for i = 1, MAX_RAID_MEMBERS, 1 do`. Read with `grep -a`,
-- because this corpus's Ace files are ISO-8859 and plain grep silently skips them.
MAX_RAID_MEMBERS = 40
-- Its two siblings, each reached one line further along the same chain
-- (`CT_RAOptions.lua:75`, then `:231`). The reference declares all three together at
-- RaidFrame.lua l.2-4, and they agree: 40 = 8 x 5. Adding them one at a time as an addon
-- asked was the wrong shape — they are one fact about the raid token space, so they land
-- as one.
NUM_RAID_GROUPS = 8
MEMBERS_PER_RAID_GROUP = 5
-- StaticPopup entry, quoted from the ref's StaticPopup.lua (INVITATION =
-- "%s invites you to a group.", GlobalStrings:2586-area; sound + 60 s silent timeout +
-- whileDead + hideOnEscape are the ref's own fields).
@@ -164,6 +164,45 @@
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBar" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="45"/></Offset></Anchor>
</Anchors>
<!-- The shelf art (ref BonusActionBarFrame.xml l.220-249), declared HIDDEN.
The header above says this art "is not modeled at all" because raised mode hides it and
we are permanently raised. That reasoning was right about the PIXELS and wrong about
the NAMES: 1219/1223 settled that the reference does not choose between shown and
absent, and "we don't show it" is not a reason for it not to exist. Four frames landed
on that principle; these are the first REGIONS to, which is why the sweep that closed
the class missed them — it ran over the frames/tables ranking, and a Texture is
neither.
Two corpus addons index them, and both do pure LAYOUT — they do not use the art, they
move it, exactly the shape 1223 found for BonusActionBarFrame:
zBarEx/zMainBar.lua:288-290 SetParent(MainMenuBar) on all three
CT_BottomBar.lua:681-692 ClearAllPoints + SetPoint off-screen
Absent, `ShapeshiftBarLeft:ClearAllPoints()` is `attempt to index global` and takes the
addon's whole handler with it.
Geometry and TexCoords quoted: Left and Right are two crops of ONE `ShapeshiftBarEnds`
file (0..0.453125 and 0.453125..0.875), Middle its own. hidden="true" is the state
raised mode leaves them in, so this still draws nothing. -->
<Layers>
<Layer level="OVERLAY">
<Texture name="ShapeshiftBarLeft" file="Interface\ShapeshiftBar\ShapeshiftBarEnds" hidden="true">
<Size><AbsDimension x="45" y="50"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<TexCoords left="0" right="0.453125" top="0" bottom="1"/>
</Texture>
<Texture name="ShapeshiftBarMiddle" file="Interface\ShapeshiftBar\ShapeshiftBarMiddle" hidden="true">
<Size><AbsDimension x="38" y="50"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftBarLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="1" top="0" bottom="1"/>
</Texture>
<Texture name="ShapeshiftBarRight" file="Interface\ShapeshiftBar\ShapeshiftBarEnds" hidden="true">
<Size><AbsDimension x="42" y="50"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftBarMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.453125" right="0.875" top="0" bottom="1"/>
</Texture>
</Layer>
</Layers>
<Frames>
<CheckButton name="ShapeshiftButton1" inherits="BenillaShapeshiftButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="11" y="3"/></Offset></Anchor></Anchors>
@@ -483,6 +483,37 @@ fn main() {
if rows.is_empty() {
println!(" (none — no addon's method tables contain that text)");
}
// ...and WHICH ADDONS each demand ranking counted. The rankings print a number per name and
// nothing could ask what it was made of, so a row could disagree with the corpus and no
// command would say so. It happened: `GetChannelList` ranked 4 while exactly ONE addon's
// source names it, and finding that took a hand-rolled grep across a symlinked corpus.
// A count you cannot open is a claim, not a measurement.
for (label, rows) in [
(
"globals",
addon_harness::wanters(&reports, pattern, |r| &r.missing_globals),
),
(
"tables",
addon_harness::wanters(&reports, pattern, |r| &r.missing_tables),
),
(
"methods",
addon_harness::wanters(&reports, pattern, |r| &r.missing_methods),
),
] {
if rows.is_empty() {
continue;
}
println!(
"\n addons whose missing-{label} list matches {pattern:?} ({}) — this is what the \
ranking counted:",
rows.len()
);
for (addon, name) in &rows {
println!(" {addon:<36} {name}");
}
}
}
if verbose {
@@ -1312,6 +1312,50 @@ fn seat_a_session(script: &mut UiScript) {
..Default::default()
}),
);
// A SPELLBOOK. A level-60 warrior with an empty one is a state no real character is in — the
// same argument this fixture already makes for a nil `UnitFactionGroup` and an empty CVar
// table, and it costs an addon the same way: `TheoryCraftEngine.lua:306` is
// `name, texture, offset, numSpells = GetSpellTabInfo(1)` then `for i=1, numSpells`, so an
// empty book is `'for' limit must be a number` and the addon dies at session start. The verb
// was never the problem — `GetSpellTabInfo` already returns all four values — the FIXTURE was.
//
// Deliberately modest, and the reason is 1209's: a fixture that seats everything stops
// resembling a session anyone has, and every row it lights up is one nobody can attribute. Two
// tabs and four spells is a Tuesday. `Attack` is slot 1 because it is on every warrior's, and
// three corpus addons look for exactly that name.
script.set_spellbook(benilla_ui::script::SpellBookState {
tabs: vec![
benilla_ui::script::SpellTabView {
name: "General".into(),
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
offset: 0,
num_spells: 2,
},
benilla_ui::script::SpellTabView {
name: "Arms".into(),
texture: Some("Interface\\Icons\\Ability_Rogue_Eviscerate".into()),
offset: 2,
num_spells: 2,
},
],
slots: vec![
spell_slot(6603, "Attack", None),
spell_slot(78, "Heroic Strike", Some("Rank 1")),
spell_slot(100, "Charge", Some("Rank 1")),
spell_slot(772, "Rend", Some("Rank 1")),
],
});
}
/// One seated spellbook slot — the fields a book reader actually reads, defaulted otherwise.
fn spell_slot(spell_id: u32, name: &str, rank: Option<&str>) -> benilla_ui::script::SpellSlotView {
benilla_ui::script::SpellSlotView {
spell_id,
name: name.to_string(),
rank: rank.map(str::to_string),
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
..Default::default()
}
}
/// Every string key in the VM's `_G`.
@@ -2341,6 +2385,35 @@ pub fn blocked_by(reports: &[AddonReport], pattern: &str) -> Vec<(String, String
/// A plain substring match against the row as printed, over all four method tables, each hit
/// labelled with the table it came from — so `--why "EditBox:SetFontObject"` names the addons and
/// `--why SetFontObject` finds every table that mentions it.
/// Which addons carry `pattern` in one of the three demand lists — the read-back those rankings
/// never had.
///
/// Every ranked table above is a **count**, and until this existed nothing could ask which addons a
/// count was made of. That is not a theoretical gap: `GetChannelList` ranked 4 while exactly one
/// corpus addon's source names it, and establishing that took a hand-rolled grep across a corpus
/// whose entries are symlinks (so `grep -r` silently reads none of them). A number you cannot open
/// is a claim rather than a measurement — the same lesson `--why`'s error read-back learned in
/// 1210/1218/1227, applied to the other three tables.
///
/// Matched case-insensitively on a substring, exactly like the error read-back, so a half-remembered
/// name still finds its row.
pub fn wanters(
reports: &[AddonReport],
pattern: &str,
pick: impl Fn(&AddonReport) -> &Vec<String>,
) -> Vec<(String, String)> {
let needle = pattern.to_ascii_lowercase();
let mut out = Vec::new();
for r in reports {
for n in pick(r) {
if n.to_ascii_lowercase().contains(&needle) {
out.push((r.name.clone(), n.clone()));
}
}
}
out
}
pub fn method_rows_matching(reports: &[AddonReport], pattern: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for r in reports {
+37
View File
@@ -103,6 +103,43 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetChannelList() → slot1, name1, slot2, name2, … over every joined channel, in join order.
//
// **The shape is settled by two independent consumers, not by a recorded signature** — wow-re
// has the address (`0x4a02d0`, `scratch/bindings.md` l.152) and no contract:
//
// · the reference's own `FCFDropDown_LoadChannels(...)` walks `for i=1, arg.n, 2` and reads
// `arg[i+1]` as the NAME (FloatingChatFrame.lua l.445-455) — so the pair is (slot, name),
// in that order, and the caller steps by two;
// · `ChatLog.lua:424` packs it with `{ GetChannelList() }` and tests
// `type(value) == "number"` to spot an id — so it is a FLAT vararg, never a table.
//
// A third witness pins the flatness harder: `AceComm-2.0.lua:334` unpacks TEN pairs in one
// statement, `local _,a,_,b,…,j = GetChannelList()`.
//
// The slot numbering is [`slot_of`]'s — position in `joined_channels` + 1 — so this verb and
// `GetChannelName` can never disagree about which channel is 3.
//
// Zero joined channels is zero returns, not nil: `{ GetChannelList() }` is then an empty
// table, which is what every caller above already handles.
//
// Demand: 4 addons, and only ONE of them names it in its own source (ChatLog). The other
// three — FuBar_BGQueueNumber, FuBar_MageFu, FuBar_TankPointsFu — reach it through their
// embedded AceComm-2.0. That gap between "greps for the name" and "wants the name" is why the
// survey's own read-back exists (`--why`, d2fcef94) and why a hand grep is not the oracle here.
g.set(
"GetChannelList",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::with_capacity(model.joined_channels.len() * 2);
for (i, name) in model.joined_channels.iter().enumerate() {
out.push(Value::Integer(i as i64 + 1));
out.push(Value::String(lua.create_string(name)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
Ok(())
}
+91 -31
View File
@@ -255,7 +255,7 @@ pub type InventorySlots = [Option<InvSlotView>; INVENTORY_SLOT_COUNT];
/// untranscribed. The oddballs among the 24: `BackSlot` shows the **Chest** art and `AmmoSlot` the
/// **Ranged** art (both confirmed by their `SlotTexture` offset pointing at that other row's
/// string, not a fresh one).
const SLOT_INFO: [(&str, i64, &str); 24] = [
const SLOT_INFO: [(&str, i64, &str); 36] = [
("AmmoSlot", 0, "Ranged"),
("HeadSlot", 1, "Head"),
("NeckSlot", 2, "Neck"),
@@ -280,6 +280,28 @@ const SLOT_INFO: [(&str, i64, &str); 24] = [
("Bag1Slot", 21, "Bag"),
("Bag2Slot", 22, "Bag"),
("Bag3Slot", 23, "Bag"),
// The twelve rows this table was short of. The shipped DBC has **36 records, not 24**, and
// `Bag1`..`Bag12` at SlotNumbers 64..75 are in the very table this binding scans — 64..69 is
// exactly the bank-bag band `ContainerIDToInventoryID` produces. This file used to call them
// "a different, unrelated numbering — not `GetInventorySlotInfo` names, out of scope"; that is
// refuted, and the scan reaches them like any other row.
//
// All twelve share ONE string-block offset with `Bag0Slot`..`Bag3Slot` — sixteen rows, one
// string (`+1001`). Read at the offset rather than resolved from the row name, because that is
// how this column works: 36 rows carry only 17 distinct offsets, which is why `BackSlot` shows
// the Chest art and `AmmoSlot` the Ranged art rather than art of their own.
("Bag1", 64, "Bag"),
("Bag2", 65, "Bag"),
("Bag3", 66, "Bag"),
("Bag4", 67, "Bag"),
("Bag5", 68, "Bag"),
("Bag6", 69, "Bag"),
("Bag7", 70, "Bag"),
("Bag8", 71, "Bag"),
("Bag9", 72, "Bag"),
("Bag10", 73, "Bag"),
("Bag11", 74, "Bag"),
("Bag12", 75, "Bag"),
];
/// The durability-alert regions in the client's own slot table (`0x806eb8`, VERIFIED wow-re
@@ -812,17 +834,57 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetInventorySlotInfo",
lua.create_function(|lua, name: String| {
let Some((_, id, art)) = SLOT_INFO.iter().find(|(n, _, _)| *n == name) else {
return Err(mlua::Error::runtime(format!(
"GetInventorySlotInfo: unknown slot name '{name}'"
)));
// **The name match is CASE-INSENSITIVE, and that was the whole bug.** `0x4c8215` calls
// `0x64a4c0` -> `0x414310`, the CRT `_strnicmp`, whose comparison folds BOTH operands
// (`0x414352 add ah,dh` / `0x41435c add al,dh`, with `dh = 0x20` and the A-Z bounds in
// `bh`/`bl`) before the byte compare — ASCII folding, which is exactly
// `eq_ignore_ascii_case`. Its locale-aware arm folds too, so the verdict does not
// depend on one. `maxlen` is `0x7fffffff` and the loop stops at the first NUL on either
// side, so it is a FULL-STRING compare, not a prefix. The non-circular control is that
// the image also ships a byte-identical case-SENSITIVE wrapper (`0x64a480` ->
// `0x40de80`, `rep cmpsb`, no fold) and this binding does not call it.
//
// Two 1.12-era corpus addons died at session start on this and both worked on the real
// client: `FuBar_AmmoFu` passes `"ammoSlot"` and `FuBar_PoisonFu` `"MAINHANDSLOT"`.
// The 36 shipped names stay pairwise distinct after folding, so first-match-wins cannot
// become ambiguous.
let Some((_, id, art)) = SLOT_INFO
.iter()
.find(|(n, _, _)| n.eq_ignore_ascii_case(&name))
else {
// The raise is faithful — there is no nil path, and `0x4c823c xor eax,eax; ret` is
// dead code after `luaL_error` longjmps. The message is the reference's own
// (`.rdata 0x848894`): no `Usage:` prefix, and the offending name is NOT
// interpolated. The `Usage:` literals either side of it in the string pool belong
// to `KeyRingButtonIDToInvSlotID` and `GetInventoryItemTexture` — the adjacency
// trap.
return Err(mlua::Error::runtime(
"Invalid inventory slot in GetInventorySlotInfo",
));
};
Ok((
*id,
// The DBC string, VERBATIM. `0x4c825b` pushes `[esi+4]` straight through
// (`0x6f3890`, `repne scasb` for the length) with no normalisation anywhere, and
// the stored bytes are `interface\paperdoll\UI-PaperDoll-Slot-Bag.blp` — a
// LOWERCASE directory, and the `.blp` extension present. Only the
// `UI-PaperDoll-Slot-` leaf is mixed case. Texture *loading* would not care (the
// asset VFS folds case), but this is a Lua-visible string: anything that compares
// it, keys a table by it or concatenates it sees these bytes.
Value::String(
lua.create_string(format!("Interface\\Paperdoll\\UI-PaperDoll-Slot-{art}"))?,
lua.create_string(format!(
"interface\\paperdoll\\UI-PaperDoll-Slot-{art}.blp"
))?,
),
false,
// `checkRelic` — the NUMBER 1, never a boolean, and only for `RangedSlot`
// (`0x4c8263 dec ecx; cmp ecx,0x11`, i.e. SlotNumber 18); nil for every other slot.
// It shipped as a constant `false` here, which is falsey like nil but is the wrong
// type for a caller that compares it to 1, and wrong outright for the ranged slot.
if *id == 18 {
Value::Integer(1)
} else {
Value::Nil
},
))
})?,
)?;
@@ -1376,36 +1438,31 @@ mod tests {
fn get_inventory_slot_info_serves_the_dbc_rows() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("HeadSlot")"#)
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("HeadSlot")"#)
.unwrap(),
(
1,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Head".into(),
false
)
(1, "interface\\paperdoll\\UI-PaperDoll-Slot-Head.blp".into())
);
// The DBC's oddballs: BackSlot shows the Chest art, AmmoSlot the Ranged art.
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("BackSlot")"#)
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("BackSlot")"#)
.unwrap(),
(
15,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Chest".into(),
false
"interface\\paperdoll\\UI-PaperDoll-Slot-Chest".to_string() + ".blp"
)
);
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("AmmoSlot")"#)
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("AmmoSlot")"#)
.unwrap(),
(
0,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Ranged".into(),
false
"interface\\paperdoll\\UI-PaperDoll-Slot-Ranged.blp".into()
)
);
// The four equipped-bag icons (decision 0216 slice 2's bag bar): ids 20..23, re-verified
// this session against the real PaperDollItemFrame.dbc — every one shares the same empty-
// slot art.
// The bag rows: the four equipped-bag icons at 20..23 AND `Bag1`..`Bag12` at 64..75, which
// this table was short of until the DBC was re-read (36 records, not 24). All SIXTEEN share
// one string-block offset, which is why they answer the same art.
const BAG_ART: &str = "interface\\paperdoll\\UI-PaperDoll-Slot-Bag.blp";
for (name, id) in [
("Bag0Slot", 20),
("Bag1Slot", 21),
@@ -1413,23 +1470,26 @@ mod tests {
("Bag3Slot", 23),
] {
assert_eq!(
s.eval::<(i64, String, bool)>(&format!(r#"return GetInventorySlotInfo("{name}")"#))
s.eval::<(i64, String)>(&format!(r#"return GetInventorySlotInfo("{name}")"#))
.unwrap(),
(
id,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Bag".into(),
false
),
(id, BAG_ART.into()),
"{name}"
);
}
for n in 1..=12i64 {
assert_eq!(
s.eval::<(i64, String)>(&format!(r#"return GetInventorySlotInfo("Bag{n}")"#))
.unwrap(),
(63 + n, BAG_ART.into()),
"Bag{n}"
);
}
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("SecondaryHandSlot")"#)
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("SecondaryHandSlot")"#)
.unwrap(),
(
17,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-SecondaryHand".into(),
false
"interface\\paperdoll\\UI-PaperDoll-Slot-SecondaryHand.blp".into()
)
);
// An unknown slot name is a Lua error (the client's own behavior).
@@ -108,3 +108,54 @@ fn nothing_is_joined_before_the_server_confirms_it() {
let id: i64 = s.eval("return GetChannelName('World')").unwrap();
assert_eq!(id, 0);
}
/// **`GetChannelList()` is a FLAT vararg of (slot, name) pairs in join order** — not a table, and
/// not name-first.
///
/// Neither wow-re nor any recorded signature pins the shape; two independent consumers do, and the
/// test asserts what each of them relies on. The reference's `FCFDropDown_LoadChannels` walks
/// `for i=1, arg.n, 2` and reads `arg[i+1]` as the name, so the pair order is (slot, name) and the
/// stride is 2. `ChatLog.lua:424` packs it with `{ GetChannelList() }` and tests
/// `type(value) == "number"` to spot an id, so the values must interleave rather than nest.
#[test]
fn get_channel_list_is_a_flat_slot_name_vararg_in_join_order() {
let s = joined();
// Exactly two pairs for two joined channels — the arity `FCFDropDown_LoadChannels` steps over.
assert_eq!(
s.eval::<i64>("return select('#', GetChannelList())")
.unwrap(),
4
);
// Pair order and join order, both at once: slot 1 is the FIRST joined, not the alphabetical
// first ("Trade - City" would sort ahead of "World").
let (s1, n1, s2, n2) = s
.eval::<(i64, String, i64, String)>("return GetChannelList()")
.unwrap();
assert_eq!((s1, n1.as_str()), (1, "World"));
assert_eq!((s2, n2.as_str()), (2, "Trade - City"));
// ChatLog's own walk: packed, the odd entries are numbers and the even ones are names.
assert!(s
.eval::<bool>(
"local t = { GetChannelList() } \
return table.getn(t) == 4 and type(t[1]) == 'number' and type(t[2]) == 'string'"
)
.unwrap());
// The slot agrees with GetChannelName's, which is the point of sharing the numbering.
assert!(s
.eval::<bool>("local i = GetChannelName('Trade - City') return i == 2")
.unwrap());
// No channels joined is ZERO returns, so `{ GetChannelList() }` is an empty table rather than
// a table of nils — every consumer above already handles that shape.
let empty = crate::script::tests::common::script();
assert_eq!(
empty
.eval::<i64>("return select('#', GetChannelList())")
.unwrap(),
0
);
}
@@ -1022,3 +1022,100 @@ fn unit_creature_type_answers_the_record_then_falls_back_to_humanoid() {
"got {err}"
);
}
/// `GetInventorySlotInfo(slotName)` — `0x4c81b0`, three returns and a **case-insensitive**,
/// full-string name match.
///
/// The case-insensitivity is the whole point: two 1.12-era corpus addons died at session start on
/// case variants of real names — `FuBar_AmmoFu` passes `"ammoSlot"`, `FuBar_PoisonFu`
/// `"MAINHANDSLOT"` — and both worked on the real client, because `0x4c8215` reaches the CRT
/// `_strnicmp`, which folds both operands.
///
/// The other two assertions are things a plausible implementation gets wrong and nothing notices:
/// the third return is the **number 1**, only for `RangedSlot`, never a boolean; and a miss
/// **raises** with the reference's own message, which carries no `Usage:` prefix and does not
/// interpolate the offending name.
#[test]
fn get_inventory_slot_info_folds_case_and_flags_only_the_ranged_slot() {
let s = script();
// The exact spelling, and the two case variants the corpus actually ships.
for name in ["AmmoSlot", "ammoSlot", "AMMOSLOT"] {
assert_eq!(
s.eval::<i64>(&format!("return GetInventorySlotInfo('{name}')"))
.unwrap(),
0,
"{name} must fold to AmmoSlot"
);
}
assert_eq!(
s.eval::<i64>("return GetInventorySlotInfo('MAINHANDSLOT')")
.unwrap(),
16
);
// Three values, and the second is the empty-slot background art the paper-doll buttons use.
assert_eq!(
s.eval::<i64>("return select('#', GetInventorySlotInfo('HeadSlot'))")
.unwrap(),
3
);
let (id, art) = s
.eval::<(i64, String)>("return GetInventorySlotInfo('HeadSlot')")
.unwrap();
assert_eq!(id, 1);
// The DBC string verbatim: LOWERCASE directory and the `.blp` extension. The binding pushes
// `[esi+4]` with no normalisation, so a caller that keys a table by this sees these bytes.
assert_eq!(art, "interface\\paperdoll\\UI-PaperDoll-Slot-Head.blp");
// checkRelic: the NUMBER 1 for the ranged slot alone, nil everywhere else — not `false`, which
// is falsey like nil but the wrong type for a caller that compares it against 1.
assert!(s
.eval::<bool>("local _,_,r = GetInventorySlotInfo('RangedSlot') return r == 1")
.unwrap());
assert!(s
.eval::<bool>("local _,_,r = GetInventorySlotInfo('HeadSlot') return r == nil")
.unwrap());
// The twelve rows this table was short of — `Bag1`..`Bag12` at SlotNumbers 64..75, of which
// 64..69 is the bank-bag band. They share ONE string offset with Bag0Slot..Bag3Slot, so all
// sixteen bag rows answer the same art.
assert_eq!(
s.eval::<i64>("return GetInventorySlotInfo('Bag1')")
.unwrap(),
64
);
assert_eq!(
s.eval::<i64>("return GetInventorySlotInfo('bag12')")
.unwrap(),
75,
"the new rows fold case like every other"
);
assert_eq!(
s.eval::<String>("local _,a = GetInventorySlotInfo('Bag6') return a")
.unwrap(),
s.eval::<String>("local _,a = GetInventorySlotInfo('Bag0Slot') return a")
.unwrap(),
"all sixteen bag rows share one string-block offset"
);
// ...and `Bag1` (64) is NOT `Bag1Slot` (21): different names, different ids, both real rows.
assert_eq!(
s.eval::<i64>("return GetInventorySlotInfo('Bag1Slot')")
.unwrap(),
21
);
// A miss raises — there is no nil path — with the reference's own string.
let err = s
.run("GetInventorySlotInfo('NoSuchSlot')")
.expect_err("an unknown slot name must raise");
let err = format!("{err}");
assert!(
err.contains("Invalid inventory slot in GetInventorySlotInfo"),
"got {err}"
);
assert!(
!err.contains("NoSuchSlot"),
"the reference does not interpolate the offending name: {err}"
);
}