Files
benilla-octo/crates/benilla-ui/src/script/compat/classic_api/container.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

784 lines
31 KiB
Rust

//! ClassicAPI's `Container` namespace (`C_Container`): modern WoW's table/named-return shape over
//! the same bag state the stock 1.12 verbs already expose (`crates/benilla-ui/src/script/container.rs`'s
//! `Model.containers`) — almost entirely translation, not new game logic. Signatures are from the
//! upstream repo's own `docs/API.md` (fetched 2026-10-05), not from memory.
//!
//! `GetContainerItemCharges` **is** built, and the row it used to carry here (the charge slot's
//! identity being unknowable) was looking in the wrong place — see its own comment below.
//!
//! Not built, and why:
//! `GetContainerItemRepairCost` — vanilla computes repair cost server-side only; the client never
//! receives or derives it standalone. `GetContainerItemQuestInfo` — `ContainerSlot` tracks no
//! quest-item flag. `IsContainerItemOpenable` — `ContainerSlot` tracks no lock/openable flag
//! (`PickupContainerItem`'s lock bool is a *trade/pickup* lock, not a lockbox). All three return
//! nil/false rather than guess. `SortBags`/`SortBankBags` are no-ops: 1.12 has no bag-sort feature
//! and this client has no sort algorithm to run, only the preference toggles below, which nothing
//! downstream reads yet.
use mlua::{Lua, Table, Value};
use crate::script::{BagAutoStore, ContainerMove, Model};
/// Vanilla's Hearthstone, a single well-known item id unchanged since 1.12 (not a value that
/// needs confirming against OctoWoW's own item data — it predates private servers). The corpus
/// confirms it independently of memory: `pfQuest/db/enUS/items.lua:5642` is `[6948] = "Hearthstone"`,
/// and it is the id pfQuest looks that row up by. Also published to Lua as the bare
/// `HEARTHSTONE_ITEM_ID` global — see [`install`].
const HEARTHSTONE_ITEM_ID: u32 = 6948;
fn item_id_at(model: &Model, bag: i64, slot: u32) -> Option<u32> {
model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.map(|s| s.item_id)
.filter(|&id| id != 0)
}
/// Every `(bag, slot)` holding `item_id`, backpack and the four worn bags only (0..=4) unless
/// told otherwise — `CalculateTotalNumberOfFreeBagSlots`'s own range, and hearthstone lookups'.
fn find_item<'a>(model: &'a Model, bags: &'a [i64], item_id: u32) -> Option<(i64, u32)> {
bags.iter().find_map(|&bag| {
model.containers.get(&bag).and_then(|c| {
c.slots
.iter()
.find(|(_, s)| s.item_id == item_id)
.map(|(&slot, _)| (bag, slot))
})
})
}
const CARRIED_BAGS: [i64; 5] = [0, 1, 2, 3, 4];
/// A bag's free-slot count: `num_slots` minus every slot key the app has pushed, resolved or
/// still in flight — anything not a key at all is the genuinely empty slot this counts.
fn free_slots(model: &Model, bag: i64) -> Vec<u32> {
let Some(c) = model.containers.get(&bag) else {
return Vec::new();
};
(1..=c.num_slots)
.filter(|slot| !c.slots.contains_key(slot))
.collect()
}
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
// `HEARTHSTONE_ITEM_ID`: the bare global modern FrameXML declares beside
// `C_Container.PlayerHasHearthstone`/`UseHearthstone` (the two verbs below), which is why it
// installs here rather than in a constants file of its own. 1.12 has no such global —
// `reference/1.12-globals.tsv` has no row for it — and the corpus assumes it exists:
//
// * `pfQuest/database.lua:2217-2241` — **the reason this is not cosmetic.** The file's last
// block decides whether the server speaks the same locale as pfQuest's shipped databases
// by comparing the Hearthstone's live name against `pfDB["items"][loc][HEARTHSTONE_ITEM_ID]`,
// and `pfDatabase.localized = true` is set *inside* that comparison's `if`. With the global
// nil, `C_Item.IsItemDataCachedByID(nil)` is false, `Item:CreateFromItemID(nil)` makes an
// empty item whose `ContinueOnItemLoad` poll can never resolve, so `localized` stays nil
// for the session — and pfQuest's `OnUpdate` returns at `quest.lua:352` on every frame
// before it reaches `UpdateQuestlog` or `SearchQuests`. That is the whole addon's quest
// tracker, map pins and minimap icons, off, with nothing logged: reading `t[nil]` is legal
// in Lua and the never-firing callback raises nothing.
// * `pfUI/libs/libbagsort.lua:19` and `pfUI/modules/itemcount.lua:16` compare a bag slot's
// item id against it; a nil there is a silently-never-taken branch, not a raise.
//
// A `nil == nil` comparison is exactly why this went unseen for so long, so the value is
// published once, from the one place that already held it.
lua.globals()
.set("HEARTHSTONE_ITEM_ID", HEARTHSTONE_ITEM_ID)?;
let t = lua.create_table()?;
t.set(
"GetContainerItemID",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(item_id_at(&model, bag, slot).map(i64::from))
})?,
)?;
t.set(
"HasContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(item_id_at(&model, bag, slot).is_some())
})?,
)?;
t.set(
"GetContainerItemInfo",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(s) = model.containers.get(&bag).and_then(|c| c.slots.get(&slot)) else {
return Ok(Value::Nil);
};
if s.item_id == 0 {
return Ok(Value::Nil);
}
let info = lua.create_table()?;
info.set("itemID", s.item_id)?;
info.set("quantity", s.count)?;
if let Some(q) = s.quality {
info.set("quality", q)?;
}
info.set("isBound", s.already_bound)?;
info.set("isReadable", s.readable)?;
info.set("isLocked", s.locked)?;
// No per-item "worthless" fact is tracked; false, not a guess either way.
info.set("hasNoValue", false)?;
info.set("isFiltered", false)?;
info.set("noValue", false)?;
if let Some(link) = &s.link {
info.set("itemLink", link.as_str())?;
info.set("hyperlink", link.as_str())?;
}
Ok(Value::Table(info))
})?,
)?;
// GetItemCooldown(itemID): scans the carried bags for the item, reusing the same
// `container_cooldowns` the stock `GetContainerItemCooldown` reads; an item not carried, or
// carried with no active cooldown, answers the reference's own "cold" triple.
t.set(
"GetItemCooldown",
lua.create_function(|lua, item_id: u32| {
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let found = find_item(&model, &CARRIED_BAGS, item_id)
.and_then(|(bag, slot)| model.container_cooldowns.get(&(bag, slot)));
Ok(match found {
Some(&(start, duration, enabled)) if start + duration > now || !enabled => {
(start, duration, i32::from(enabled))
}
_ => (0.0, 0.0, 1),
})
})?,
)?;
t.set(
"GetContainerItemDurability",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let durability = model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.and_then(|s| s.durability);
Ok(match durability {
Some((cur, max)) => (
Value::Integer(i64::from(cur)),
Value::Integer(i64::from(max)),
),
None => (Value::Nil, Value::Nil),
})
})?,
)?;
t.set(
"GetContainerNumFreeSlots",
lua.create_function(|lua, bag: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(free_slots(&model, bag).len())
})?,
)?;
t.set(
"GetContainerFreeSlots",
lua.create_function(|lua, bag: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
lua.create_sequence_from(free_slots(&model, bag))
})?,
)?;
t.set(
"CalculateTotalNumberOfFreeBagSlots",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(CARRIED_BAGS
.iter()
.map(|&bag| free_slots(&model, bag).len())
.sum::<usize>())
})?,
)?;
// Always nil: 1.12 has no quest-item tracking on a bag slot and no equipment-set feature at
// all, so there is never a quest-item or a set to report — a real answer, not a stand-in.
t.set(
"GetContainerItemQuestInfo",
lua.create_function(|_, (_, _): (i64, u32)| Ok(Value::Nil))?,
)?;
t.set(
"GetContainerItemEquipmentSetInfo",
lua.create_function(|_, (_, _): (i64, u32)| Ok(Value::Nil))?,
)?;
t.set(
"GetContainerItemRepairCost",
lua.create_function(|_, (_, _): (i64, u32)| Ok(Value::Nil))?,
)?;
// GetContainerItemCharges(bag, slot) -> uses left in the item's charge pool.
//
// **This reverses this file's earlier decision not to build it**, and the objection recorded
// there was wrong about where the data lives rather than right-but-outweighed: it looked for
// the charge slot's identity in `benilla-formats`' item template, which is DBC-shaped and has
// no such column — but item templates do not come from a DBC here, they come over the wire.
// `ItemInfo::spells` carries all five `ItemSpell` blocks with their own signed `SpellCharges`
// (`benilla-protocol`'s `ItemSpellEntry`), so "which block is a consumable charge pool" is
// answered by the block itself, and `ui_items/feed.rs`'s `charge_pool` was already picking it
// out — the tooltip's "N Charges" line has been printed off that same choice all along. All
// that was missing was carrying the *instance's* countdown of it onto `ContainerSlot`.
//
// Always a number, never nil, for any `(bag, slot)` — an item with no pool, an empty slot and
// an absent bag all answer 0, the same shape `GetContainerNumFreeSlots` above takes for an
// absent bag. 0 is true of each: there is no charge pool to have uses left in. It also has to
// be a number, because pfUI's bag module compares the return with `> 1` unguarded
// (`modules/bags.lua:452`), and the nil this used to answer raised there — which aborted
// `CreateBags` partway through the backpack and so never reached its `CreateBags("bank")`
// call, leaving `pfUI.bags[-1]` and `[5..11]` uncreated and the next `CheckFullUpdate`
// indexing nil at `:210`. One nil, two reported faults.
//
// **What is chosen rather than confirmed**: "uses left" is the member's documented meaning,
// but upstream's exact arithmetic is not in hand (`docs/ADDON_COMPAT.md`'s row on why — the
// published `docs/API.md` is on `master`, not the `main` the old row cited, and the body of
// the Item section could not be read). The other plausible candidate is `stackCount x usesPer
// Item`, which differs from this only for a *stack* of charged items. The one installed call
// site cannot tell them apart and that is checked, not assumed: pfUI reaches this verb only
// inside `if count == 1`, where the two candidates coincide, and only ever tests `> 1`.
t.set(
"GetContainerItemCharges",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.map_or(0, |s| s.charges))
})?,
)?;
t.set(
"IsContainerItemOpenable",
lua.create_function(|_, (_, _): (i64, u32)| Ok(false))?,
)?;
t.set(
"PlayerHasHearthstone",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(find_item(&model, &CARRIED_BAGS, HEARTHSTONE_ITEM_ID).is_some())
})?,
)?;
t.set(
"UseHearthstone",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some((bag, slot)) = find_item(&model, &CARRIED_BAGS, HEARTHSTONE_ITEM_ID) else {
return Ok(false);
};
model.container_uses.push((bag, slot));
Ok(true)
})?,
)?;
// SwapItems/MoveItem: queued exactly as `PickupContainerItem`'s own place-onto-another-slot
// leg does (`script/container.rs`'s `queue_move`), just without needing the cursor round
// trip; both answer whether the source actually held an item to move.
t.set(
"SwapItems",
lua.create_function(
|lua, (src_bag, src_slot, dst_bag, dst_slot): (i64, u32, i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if item_id_at(&model, src_bag, src_slot).is_none() {
return Ok(false);
}
model.container_moves.push(ContainerMove {
src_bag,
src_slot,
dst_bag,
dst_slot,
count: None,
});
Ok(true)
},
)?,
)?;
t.set(
"MoveItem",
lua.create_function(
|lua, (src_bag, src_slot, dst_bag, dst_slot, count): (i64, u32, i64, u32, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(stack) = model
.containers
.get(&src_bag)
.and_then(|c| c.slots.get(&src_slot))
.filter(|s| s.item_id != 0)
.map(|s| s.count)
else {
return Ok(false);
};
model.container_moves.push(ContainerMove {
src_bag,
src_slot,
dst_bag,
dst_slot,
count: (count < stack).then_some(count),
});
Ok(true)
},
)?,
)?;
t.set(
"AutoStoreItem",
lua.create_function(
|lua, (src_bag, src_slot, dst_bag): (i64, u32, Option<i64>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if item_id_at(&model, src_bag, src_slot).is_none() {
return Ok(());
}
model.bag_autostores.push(BagAutoStore {
src_bag,
src_slot,
dst_bag: dst_bag.unwrap_or(0),
count: None,
});
Ok(())
},
)?,
)?;
t.set("SortBags", lua.create_function(|_, ()| Ok(()))?)?;
t.set("SortBankBags", lua.create_function(|_, ()| Ok(()))?)?;
t.set(
"GetSortBagsRightToLeft",
lua.create_function(|lua, ()| {
Ok(lua
.app_data_ref::<Model>()
.expect("model app_data")
.bag_sort_right_to_left)
})?,
)?;
t.set(
"SetSortBagsRightToLeft",
lua.create_function(|lua, enable: bool| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.bag_sort_right_to_left = enable;
Ok(())
})?,
)?;
t.set(
"GetBackpackAutosortDisabled",
lua.create_function(|lua, ()| {
Ok(lua
.app_data_ref::<Model>()
.expect("model app_data")
.backpack_autosort_disabled)
})?,
)?;
t.set(
"SetBackpackAutosortDisabled",
lua.create_function(|lua, disable: bool| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.backpack_autosort_disabled = disable;
Ok(())
})?,
)?;
t.set(
"GetBankAutosortDisabled",
lua.create_function(|lua, ()| {
Ok(lua
.app_data_ref::<Model>()
.expect("model app_data")
.bank_autosort_disabled)
})?,
)?;
t.set(
"SetBankAutosortDisabled",
lua.create_function(|lua, disable: bool| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.bank_autosort_disabled = disable;
Ok(())
})?,
)?;
Ok(t)
}
#[cfg(test)]
mod tests {
use crate::script::{ContainerSlot, ContainerState, UiScript};
fn backpack_with(slot: u32, item_id: u32, count: u32) -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
slot,
ContainerSlot {
item_id,
count,
..Default::default()
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
#[test]
fn basic_item_reads_wrap_the_stock_slot_data() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack_with(1, 117, 5)));
assert_eq!(
s.eval::<i64>("return C_Container.GetContainerItemID(0, 1)")
.unwrap(),
117
);
assert!(s
.eval::<bool>("return C_Container.HasContainerItem(0, 1)")
.unwrap());
assert!(!s
.eval::<bool>("return C_Container.HasContainerItem(0, 2)")
.unwrap());
assert_eq!(
s.eval::<i64>("local info = C_Container.GetContainerItemInfo(0, 1) return info.itemID")
.unwrap(),
117
);
assert_eq!(
s.eval::<i64>(
"local info = C_Container.GetContainerItemInfo(0, 1) return info.quantity"
)
.unwrap(),
5
);
assert!(s
.eval::<bool>("return C_Container.GetContainerItemInfo(0, 2) == nil")
.unwrap());
}
#[test]
fn free_slots_exclude_every_known_key_resolved_or_not() {
let mut s = UiScript::new().unwrap();
let mut state = backpack_with(1, 117, 5);
state.num_slots = 4;
state.slots.insert(3, ContainerSlot::default()); // in-flight, still "known"
s.set_container(0, Some(state));
assert_eq!(
s.eval::<i64>("return C_Container.GetContainerNumFreeSlots(0)")
.unwrap(),
2,
"slots 2 and 4 only"
);
assert_eq!(
s.eval::<Vec<i64>>("return C_Container.GetContainerFreeSlots(0)")
.unwrap(),
vec![2, 4]
);
}
#[test]
fn hearthstone_lookup_scans_the_carried_bags() {
let mut s = UiScript::new().unwrap();
assert!(!s
.eval::<bool>("return C_Container.PlayerHasHearthstone()")
.unwrap());
s.set_container(2, Some(backpack_with(3, 6948, 1)));
assert!(s
.eval::<bool>("return C_Container.PlayerHasHearthstone()")
.unwrap());
assert!(s
.eval::<bool>("return C_Container.UseHearthstone()")
.unwrap());
assert_eq!(s.take_container_uses(), vec![(2, 3)]);
}
#[test]
fn swap_and_move_queue_container_moves() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack_with(1, 117, 5)));
assert!(s
.eval::<bool>("return C_Container.SwapItems(0, 1, 0, 2)")
.unwrap());
assert_eq!(
s.take_container_moves(),
vec![crate::script::ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: None,
}]
);
assert!(
!s.eval::<bool>("return C_Container.SwapItems(0, 9, 0, 2)")
.unwrap(),
"nothing in slot 9"
);
assert!(s
.eval::<bool>("return C_Container.MoveItem(0, 1, 0, 2, 3)")
.unwrap());
assert_eq!(
s.take_container_moves(),
vec![crate::script::ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: Some(3),
}],
"partial, below the 5-stack"
);
assert!(s
.eval::<bool>("return C_Container.MoveItem(0, 1, 0, 2, 5)")
.unwrap());
assert_eq!(
s.take_container_moves(),
vec![crate::script::ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: None,
}],
"the whole stack reads as a plain move, no split"
);
}
#[test]
fn autostore_queues_when_something_is_there() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack_with(1, 117, 5)));
s.eval::<()>("C_Container.AutoStoreItem(0, 1)").unwrap();
s.eval::<()>("C_Container.AutoStoreItem(0, 9)").unwrap(); // empty, no-op
assert_eq!(
s.take_bag_autostores(),
vec![crate::script::BagAutoStore {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
count: None,
}]
);
}
#[test]
fn sort_toggles_round_trip_as_stored_preferences() {
let s = UiScript::new().unwrap();
assert!(!s
.eval::<bool>("return C_Container.GetSortBagsRightToLeft()")
.unwrap());
s.eval::<()>("C_Container.SetSortBagsRightToLeft(true)")
.unwrap();
assert!(s
.eval::<bool>("return C_Container.GetSortBagsRightToLeft()")
.unwrap());
s.eval::<()>("C_Container.SetBackpackAutosortDisabled(true)")
.unwrap();
assert!(s
.eval::<bool>("return C_Container.GetBackpackAutosortDisabled()")
.unwrap());
s.eval::<()>("C_Container.SetBankAutosortDisabled(true)")
.unwrap();
assert!(s
.eval::<bool>("return C_Container.GetBankAutosortDisabled()")
.unwrap());
// No-ops that must not raise.
s.eval::<()>("C_Container.SortBags() C_Container.SortBankBags()")
.unwrap();
}
#[test]
fn durability_and_quest_and_equipment_set_info_are_honest_about_what_is_not_tracked() {
let mut s = UiScript::new().unwrap();
let mut state = backpack_with(1, 117, 1);
state.slots.get_mut(&1).unwrap().durability = Some((40, 100));
s.set_container(0, Some(state));
assert_eq!(
s.eval::<(i64, i64)>("return C_Container.GetContainerItemDurability(0, 1)")
.unwrap(),
(40, 100)
);
assert!(s
.eval::<bool>("return C_Container.GetContainerItemQuestInfo(0, 1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return C_Container.GetContainerItemEquipmentSetInfo(0, 1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return C_Container.GetContainerItemRepairCost(0, 1) == nil")
.unwrap());
assert!(!s
.eval::<bool>("return C_Container.IsContainerItemOpenable(0, 1)")
.unwrap());
}
/// pfUI's bag module reads this one unguarded — `local charges = ...GetContainerItemCharges(
/// bag, slot) if charges > 1 then` (`modules/bags.lua:451-452`) — so the type matters as much
/// as the number: a nil raises "attempt to compare number with nil" *inside* `CreateBags`,
/// which then never reaches its own `CreateBags("bank")` call and leaves `pfUI.bags[-1]` and
/// `[5..11]` absent for the next `CheckFullUpdate` to index at `:210`. The body below is that
/// loop, transcribed, so the test fails the same way the addon did.
#[test]
fn container_item_charges_answers_a_number_for_every_slot_so_pfuis_bag_loop_cannot_raise() {
let mut s = UiScript::new().unwrap();
let mut state = backpack_with(1, 117, 1);
// A five-charge pool with two uses spent: `ContainerSlot::charges` is the *instance's*
// remaining count, so this is 3, not the template's 5.
state.slots.get_mut(&1).unwrap().charges = 3;
s.set_container(0, Some(state));
assert_eq!(
s.eval::<i64>("return C_Container.GetContainerItemCharges(0, 1)")
.unwrap(),
3,
"the uses left on the item in that slot"
);
// An item with no pool, an empty slot and an absent bag: 0, and a number in every case.
for call in [
"C_Container.GetContainerItemCharges(0, 2)",
"C_Container.GetContainerItemCharges(5, 1)",
] {
assert_eq!(
s.eval::<String>(&format!("return type({call})")).unwrap(),
"number",
"{call} must answer a number: pfUI compares it with `> 1` unguarded"
);
assert_eq!(s.eval::<i64>(&format!("return {call}")).unwrap(), 0);
}
// `modules/bags.lua:446-455`, transcribed: the block that raised.
s.run(
r#"
local bag, slot = 0, 1
local count = 1
if count == 1 then
local charges = C_Container.GetContainerItemCharges(bag, slot)
if charges > 1 then
count = charges
end
end
assert(count == 3, "pfUI shows the charge count in the stack corner")
-- and the same block over every bag the module walks, including the ones with no
-- container pushed at all, which is what `for bag = -2,11` reaches.
for bag = -2, 11 do
for slot = 1, 4 do
local c = C_Container.GetContainerItemCharges(bag, slot)
if c > 1 then count = c end
end
end
"#,
)
.expect("pfUI's charge block must not raise for any bag in -2..11");
}
#[test]
fn item_cooldown_scans_carried_bags_by_item_id() {
let mut s = UiScript::new().unwrap();
s.tick(100.0);
let mut state = backpack_with(1, 117, 1);
state.slots.get_mut(&1).unwrap().cooldown = Some((96_000, 10_000, true));
s.set_container(0, Some(state));
assert!(s
.eval::<bool>(
"local st, d, e = C_Container.GetItemCooldown(117) return st == 96 and d == 10 and e == 1"
)
.unwrap());
assert!(s
.eval::<bool>(
"local st, d, e = C_Container.GetItemCooldown(9999) return st == 0 and d == 0 and e == 1"
)
.unwrap());
}
/// **pfQuest's locale check, transcribed** (`database.lua:2215-2246`), the last block of the
/// last file of its `<Include>` — and the single gate the whole addon runs behind:
/// `quest.lua:352` is `if not pfDatabase.localized then return end`, the first statement of
/// `pfQuest`'s `OnUpdate`, so until this block sets the flag there is no `UpdateQuestlog`, no
/// `SearchQuests`, no queue drain, no map node and no tracker row.
///
/// The flag is assigned *inside* `if name and name ~= "" and pfDB["items"][loc] and
/// pfDB["items"][loc][HEARTHSTONE_ITEM_ID]`, which makes the global load-bearing twice over: as
/// the argument that decides whether the cached branch is even taken, and as the *key* into
/// pfQuest's own shipped locale table. So this fails against a nil global (the empty
/// `Item:CreateFromItemID(nil)` whose poll can never resolve) and against a wrong id (the key
/// misses and the `if` is never entered) alike — and neither failure raises, which is exactly
/// why the symptom was an addon that drew nothing with a clean error log.
#[test]
fn pfquests_locale_check_sets_its_run_gate_from_the_hearthstone_the_bags_hold() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack_with(1, 6948, 1)));
// The bag push is what makes the app query the template (`ui_items/feed.rs`'s
// `items.template(entry, …)` per slot), so by the time an addon loads the answer is in the
// store and `IsItemDataCachedByID` takes the synchronous branch — as it does here.
s.set_item_template(
6948,
crate::script::ItemTemplateView {
name: "Hearthstone".into(),
quality: 1,
..Default::default()
},
);
s.run(
r#"
-- pfQuest's own locale table, reduced to the row this check reads
-- (`db/enUS/items.lua:5642`), and the two objects the block writes through
local loc = GetLocale()
pfDB = { items = { [loc] = { [6948] = "Hearthstone" } } }
pfDatabase = {}
function pfDatabase:BuildNameIndex() self.name_index_built = true end
function pfDatabase:BuildStaticRejectSet() self.reject_set_built = true end
local function RunNameCheck(name)
if name and name ~= "" and pfDB["items"][loc] and pfDB["items"][loc][HEARTHSTONE_ITEM_ID] then
if not strfind(name, pfDB["items"][loc][HEARTHSTONE_ITEM_ID], 1) then
-- the unlocalized-server arm: swaps the locale tables, not exercised here
pfDatabase.swapped = true
end
pfDatabase.localized = true
pfDatabase:BuildNameIndex()
pfDatabase:BuildStaticRejectSet()
end
end
if C_Item.IsItemDataCachedByID(HEARTHSTONE_ITEM_ID) then
RunNameCheck(C_Item.GetItemNameByID(HEARTHSTONE_ITEM_ID))
else
local item = Item:CreateFromItemID(HEARTHSTONE_ITEM_ID)
item:ContinueOnItemLoad(function()
RunNameCheck(item:GetItemName())
end)
end
"#,
)
.expect("pfQuest's locale check must not raise");
assert_eq!(
s.eval::<Option<bool>>("return pfDatabase.localized")
.unwrap(),
Some(true),
"the run gate every pfQuest feature sits behind (`quest.lua:352`) must be open"
);
assert_eq!(
s.eval::<Option<bool>>("return pfDatabase.swapped").unwrap(),
None,
"an enUS server matching the enUS table must not take the locale-swap arm"
);
assert_eq!(
s.eval::<Option<bool>>("return pfDatabase.name_index_built")
.unwrap(),
Some(true),
"and the derived indexes the same block owns got rebuilt"
);
}
}