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
784 lines
31 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|