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:
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user