Item tooltips show the lock's key or skill line and a gift's giver (#1454)

Item tooltips lacked the lock's sub-line and a wrapped gift's giver.

The reference: the lock check (`0x52bd57`) takes the Lock row's slots in place. The first slot picks the arm: a key names its item in white (`0x52bf30`), and a skill names the LockType and the required value, coloured by the walk at `0x5da5e0`. A blank LockType name prints blank; only a missing row reads `UNKNOWN` (`0x52bede`). A wrapped item (flag `0x8`) prints `ITEM_WRAPPED_BY` with `GIFTCREATOR`, from the object or, for the trade partner's slots, from the block (`0x534559`, `0x534574`). Real lockboxes use locks whose first slot is empty, so they show no sub-line, as in 1.12.

The rows keep all eight slots, and both trade sides carry the wrapped word and the giver. Tests cover each arm, a blank name, and gifts in bags and trade.

Fixes #1440
Fixes #1441

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-16 14:26:56 +07:00
committed by GitHub
parent 9ff8f6aff4
commit bc52e8a7f4
33 changed files with 1044 additions and 142 deletions
@@ -1597,6 +1597,7 @@ fn seed_cooldown_filmstrip(
cooldown: Some(((now_ms - fraction * span_ms) as i64, span_ms as u32, true)),
readable: false,
creator: None,
gift_creator: None,
flags: 0,
already_bound: false,
occupied_seats: None,
@@ -1657,6 +1658,7 @@ fn seed_bag_window(
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
already_bound: false,
occupied_seats: None,
@@ -1751,6 +1753,7 @@ fn seed_equipped_bags(
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
already_bound: false,
occupied_seats: None,
+45
View File
@@ -222,6 +222,22 @@ pub(crate) fn enchant_lines(
lines
}
/// The name behind a wrapped item's `ITEM_WRAPPED_BY` line: only an item carrying WRAPPED `0x8`
/// takes the gift arm (`0x52b789`, `0x52e1b9`), which reads `ITEM_FIELD_GIFTCREATOR` (`0x52e32a`)
/// and asks the name cache (`0x52e29d`). A zero guid, an unwrapped item or a name not yet
/// answered gives `None`, and only the first two ask nothing.
pub(crate) fn gift_creator_name(
fields: &ObjectFields,
names: &crate::names::NameCache,
commands: &NetCommands,
) -> Option<String> {
if fields.item_flags()? & benilla_protocol::messages::ITEM_DYNFLAG_WRAPPED == 0 {
return None;
}
let giver = fields.item_gift_creator()?;
names.resolve_player(giver, commands).map(str::to_string)
}
/// `0x5da2c0`: already bound when `ITEM_FIELD_FLAGS & 1` is set or a live enchant slot names a
/// binding `SpellItemEnchantment` row (`0x5da300` to `0x5da320`). The enchant cursor's bind
/// question (gate `0x495d60`) and the tooltip's Soulbound line both read it. It reads the raw
@@ -1073,6 +1089,35 @@ mod tests {
ObjectFields::from_pairs(&[(21, flags), (22, slot0)])
}
/// The gift arm (`0x52e1b9`) asks the name cache for the gift creator of a wrapped item and no
/// other: an unwrapped item, a zero guid and a name still in flight add no name.
#[test]
fn only_a_wrapped_item_names_its_gift_creator() {
let (tx, rx) = crossbeam_channel::unbounded();
let cmds = NetCommands(tx);
let mut names = crate::names::NameCache::default();
let giver =
|flags: u32, guid: u32| ObjectFields::from_pairs(&[(21, flags), (12, guid), (13, 0)]);
// Unwrapped, even with a guid set: no ask, no name.
assert_eq!(gift_creator_name(&giver(0, 5), &names, &cmds), None);
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
// Wrapped, no giver: nothing to ask.
assert_eq!(gift_creator_name(&giver(0x8, 0), &names, &cmds), None);
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
// Wrapped, the name in flight: asked once, no name yet.
assert_eq!(gift_creator_name(&giver(0x8, 5), &names, &cmds), None);
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::NameQuery { guid: 5 })
));
// Answered.
names.insert_player(5, "Santa".to_string(), None);
assert_eq!(
gift_creator_name(&giver(0x8, 5), &names, &cmds).as_deref(),
Some("Santa")
);
}
/// `0x5da2c0`: the Firestone shape binds and prints no line, so a predicate read off
/// [`enchant_lines`] would call it unbound.
#[test]
+3
View File
@@ -547,6 +547,7 @@ fn slot_view(
.item_creator()
.filter(|&g| g != 0)
.and_then(|g| names.resolve_player(g, commands).map(str::to_string));
let gift_creator = crate::items::gift_creator_name(obj, names, commands);
// All seven enchant slots: our own gear streams as item objects, where an inspected player's
// descriptor carries only two.
let enchants = crate::items::enchant_lines(
@@ -625,6 +626,7 @@ fn slot_view(
bar_placeable,
consumable,
creator,
gift_creator,
enchants,
duration_ms,
})
@@ -715,6 +717,7 @@ fn inventory_slots(
bar_placeable: false,
consumable: false,
creator: None,
gift_creator: None,
// No ammo instance streams, only its template id: no enchants, no countdown.
enchants: Vec::new(),
duration_ms: None,
+1
View File
@@ -1786,6 +1786,7 @@ mod bind_confirm_tests {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
+6 -35
View File
@@ -412,7 +412,7 @@ pub(super) fn feed_item_stats(
commands: Res<NetCommands>,
spells: Option<Res<crate::ui_action::Spells>>,
// The caster the `$`-tokens read: its spell modifiers and its skills.
(spell_mods, self_q, locks): CasterReads,
(spell_mods, self_q): CasterReads,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
// The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`.
home_bind: Option<Res<crate::net::HomeBind>>,
@@ -489,7 +489,7 @@ pub(super) fn feed_item_stats(
} else {
mod_sensitive.remove(&id);
}
let mut view = template_view(
let view = template_view(
&t,
spell_res,
Some(&spell_mods),
@@ -503,9 +503,6 @@ pub(super) fn feed_item_stats(
icons.as_deref(),
&get,
);
// A lock id with no `Lock.dbc` row is no lock to the builder (`0x52bd6b`..
// `0x52bda9`); with the catalog unloaded the id stands.
view.lock_id = known_lock_id(view.lock_id, locks.as_deref().map(|l| &l.0));
Some((id, view))
})
.collect()
@@ -515,24 +512,12 @@ pub(super) fn feed_item_stats(
}
}
/// What the `$`-tokens read of the caster, and the `Lock.dbc` the LOCKED line needs a row from
/// (`0x52bd6b`).
/// What the `$`-tokens read of the caster.
type CasterReads<'w, 's> = (
Res<'w, crate::spell::SpellModifiers>,
Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
Option<Res<'w, crate::go_templates::Locks>>,
);
/// The template's lock id when `Lock.dbc` holds its row, else 0: the builder's LOCKED line starts
/// from that row (`0x52bd57`..`0x52bd76`), and a missing one is no lock. With the catalog not
/// loaded the id stands.
fn known_lock_id(lock_id: u32, locks: Option<&benilla_formats::LockCatalog>) -> u32 {
match locks {
Some(l) if l.slots(lock_id).is_none() => 0,
_ => lock_id,
}
}
/// The player state the tooltip's red requirement lines check: level, class, race, skills,
/// proficiencies (`SMSG_SET_PROFICIENCY`) and reputation ranks (the DBC base plus the
/// `SMSG_INITIALIZE_FACTIONS` standing), pushed on change.
@@ -708,6 +693,7 @@ fn resolve_slot(
rolls.enchants,
),
);
let gift_creator = crate::items::gift_creator_name(fields, names, commands);
// A container's filled seats, which `ClickTradeButton` counts (`0x4bff50`).
let occupied_seats = fields.is_container().then(|| {
let n = fields.container_num_slots().unwrap_or(0).min(36) as u8;
@@ -732,6 +718,7 @@ fn resolve_slot(
count,
readable,
creator,
gift_creator,
flags,
already_bound,
occupied_seats,
@@ -778,6 +765,7 @@ fn resolve_slot(
guid,
readable,
creator,
gift_creator,
flags,
already_bound,
occupied_seats,
@@ -1484,23 +1472,6 @@ mod tests {
BANK_CONTAINER, BANK_SLOTS,
};
/// A lock id with no `Lock.dbc` row is no lock to the item builder (`0x52bd6b`..`0x52bda9`).
#[test]
fn a_lock_id_without_a_row_is_no_lock() {
use benilla_formats::{LockCatalog, LockSlot, LOCK_KEY_ITEM};
let mut slots = [LockSlot::default(); benilla_formats::MAX_LOCK_SLOTS];
slots[0].key_type = LOCK_KEY_ITEM;
let catalog = LockCatalog::from_rows([(7, slots)]);
assert_eq!(super::known_lock_id(7, Some(&catalog)), 7);
assert_eq!(super::known_lock_id(8, Some(&catalog)), 0);
assert_eq!(
super::known_lock_id(8, None),
8,
"no catalog, the id stands"
);
assert_eq!(super::known_lock_id(0, Some(&catalog)), 0);
}
/// The view carries what `GetTradeSkillItemStats` reads beyond the tooltip: the item level, and
/// the five spell slots whose id names a `Spell.dbc` row with their charges, in slot order.
#[test]
+37 -22
View File
@@ -1,6 +1,6 @@
//! The inputs of the item tooltip's lock check (`0x5da5e0`): `Lock.dbc` pushed whole once, and what
//! the active player opens locks with, pushed on change. The walk and the colour are the engine's
//! ([`benilla_ui::script::check_lock`]).
//! The inputs of the item tooltip's lock check (`0x5da5e0`): `Lock.dbc` and `LockType.dbc`'s names
//! pushed whole once, and what the active player opens locks with, pushed on change. The walk and
//! the colour are the engine's ([`benilla_ui::script::check_lock`]).
use std::collections::{BTreeSet, HashMap, HashSet};
@@ -11,10 +11,12 @@ use benilla_ui::script::{LockOpener, LockOpeners, LockSlotView, UiScript};
use crate::ui_script::gate;
/// Pushes every `Lock.dbc` row once per VM, empty slots dropped: the walk skips a type 0 slot.
/// Pushes every `Lock.dbc` row and `LockType.dbc` name once per VM. Both load together; without
/// `LockType.dbc` every lock kind reads `UNKNOWN`, as a missing row does.
pub(super) fn feed_lock_rows(
script: Option<NonSendMut<UiScript>>,
locks: Option<Res<crate::go_templates::Locks>>,
lock_types: Option<Res<crate::go_templates::LockTypes>>,
mut pushed: Local<crate::ui_script::VmMemo<bool>>,
) {
let Some(mut script) = script else {
@@ -28,18 +30,24 @@ pub(super) fn feed_lock_rows(
};
let rows = row_views(&locks.0);
script.set_lock_rows(rows);
script.set_lock_type_names(
lock_types
.iter()
.flat_map(|t| t.0.names())
.map(|(id, name)| (id, name.to_string()))
.collect(),
);
*pushed.get(&script) = true;
}
/// Every row as the walk reads it, empty slots dropped (a type 0 slot is skipped) and the rest in
/// slot order.
/// Every row as the reference holds it, all eight slots in order: the walk skips a type 0 slot,
/// and the line under `LOCKED` reads the first slot's type whatever it is (`0x52be5d`).
fn row_views(catalog: &LockCatalog) -> HashMap<u32, Vec<LockSlotView>> {
catalog
.rows()
.map(|(id, slots)| {
let slots = slots
.iter()
.filter(|s| s.key_type != benilla_formats::LOCK_KEY_NONE)
.map(|s| LockSlotView {
key_type: s.key_type,
index: s.index,
@@ -181,7 +189,7 @@ mod tests {
}
#[test]
fn a_rows_empty_slots_are_dropped_and_the_rest_keep_their_order() {
fn a_row_keeps_all_its_slots_in_place() {
let mut slots = [LockSlot::default(); MAX_LOCK_SLOTS];
slots[1] = LockSlot {
key_type: 1,
@@ -199,21 +207,28 @@ mod tests {
(7, slots),
(8, [LockSlot::default(); MAX_LOCK_SLOTS]),
]));
assert_eq!(rows[&7].len(), MAX_LOCK_SLOTS);
assert_eq!(rows[&7][0], LockSlotView::default(), "slot 0 stays empty");
assert_eq!(
rows[&7],
[
LockSlotView {
key_type: 1,
index: 5060,
skill: 0
},
LockSlotView {
key_type: 2,
index: 1,
skill: 25
}
]
rows[&7][1],
LockSlotView {
key_type: 1,
index: 5060,
skill: 0
}
);
assert_eq!(
rows[&7][3],
LockSlotView {
key_type: 2,
index: 1,
skill: 25
}
);
assert_eq!(
rows[&8].len(),
MAX_LOCK_SLOTS,
"a row of empty slots stays a row"
);
assert!(rows[&8].is_empty(), "a row of empty slots stays a row");
}
}
@@ -1229,6 +1229,7 @@ fn shipped_bag_frame_drives_end_to_end() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -407,6 +407,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -492,6 +493,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -607,6 +609,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -637,6 +640,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -832,6 +836,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -926,6 +931,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -1070,6 +1076,7 @@ pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -1482,6 +1489,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
guid: 0x70d,
equip_slots: vec![20],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -72,6 +72,7 @@ fn inventory_with_head_item() -> InventorySlots {
guid: 0x70e,
equip_slots: vec![1],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -106,6 +107,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -404,6 +404,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -36,6 +36,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -34,6 +34,7 @@ fn one_item_backpack() -> ContainerState {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -283,6 +284,7 @@ fn escape_closes_an_open_stack_split_frame() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -716,6 +716,7 @@ fn backpack() -> ContainerState {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -84,6 +84,7 @@ fn seed_items(s: &mut UiScript) {
guid: 0x715,
equip_slots: vec![1],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -139,6 +140,7 @@ fn seed_items(s: &mut UiScript) {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -214,6 +216,7 @@ fn doll_hover_renders_the_live_instance() {
guid: 0x717,
equip_slots: vec![1],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
+144 -8
View File
@@ -590,6 +590,7 @@ fn resolve_slot(
item: &TradeItem,
items: &Items,
icons: Option<&ItemDisplays>,
names: &NameCache,
commands: &NetCommands,
) -> TradeSlotItem {
let template = items.template(item.entry, 0, commands);
@@ -610,6 +611,12 @@ fn resolve_slot(
(Some(n), Some(q)) => Some(crate::ui_items::item_link(item.entry, n, q)),
_ => None,
};
// Only a wrapped gift asks for its giver, and a zero guid asks nothing (`0x52e288`,
// `0x52e29d`).
let gift_creator = (item.wrapped && item.gift_creator != 0)
.then(|| names.resolve_player(item.gift_creator, commands))
.flatten()
.map(str::to_string);
TradeSlotItem {
item_id: item.entry,
name,
@@ -619,6 +626,8 @@ fn resolve_slot(
inventory_type,
lock_id: item.lock_id,
object_flags: None,
wrapped: item.wrapped,
gift_creator,
enchantment: None,
link,
}
@@ -652,6 +661,9 @@ fn own_item(
let fields = objects.object(guid)?;
let entry = fields.object_entry()?;
let flags = fields.item_flags().unwrap_or(0);
// The placed item's WRAPPED flag and giver, which its slot's tooltip reads off the object
// (`0x52b789`, `0x52e32a`).
let wrapped = flags & benilla_protocol::messages::ITEM_DYNFLAG_WRAPPED != 0;
let display_id = items
.template(entry, guid, commands)
.map(|t| t.display_info_id)
@@ -661,8 +673,8 @@ fn own_item(
entry,
display_id,
count,
wrapped: false,
gift_creator: 0,
wrapped,
gift_creator: fields.item_gift_creator().unwrap_or(0),
perm_enchant: 0,
creator: 0,
charges: 0,
@@ -687,12 +699,13 @@ fn resolve_side(
enchant: Option<&str>,
items: &Items,
icons: Option<&ItemDisplays>,
names: &NameCache,
commands: &NetCommands,
) -> TradeSideState {
let mut slots: [Option<TradeSlotItem>; TRADE_SLOT_COUNT] = Default::default();
for (i, s) in offer.slots.iter().enumerate() {
slots[i] = s.map(|it| {
let mut row = resolve_slot(&it, items, icons, commands);
let mut row = resolve_slot(&it, items, icons, names, commands);
// Our items are live objects; the partner's are blocks (`0x5342ed`, `0x53456b`).
row.object_flags = ours.then_some(offer.object_flags[i]);
if i == ENCHANT_SLOT {
@@ -712,6 +725,7 @@ fn snapshot(
items: &Items,
icons: Option<&ItemDisplays>,
spells: Option<&crate::ui_action::Spells>,
names: &NameCache,
commands: &NetCommands,
) -> Option<TradeState> {
if !trade.open {
@@ -732,6 +746,7 @@ fn snapshot(
name(trade.their.enchant_spell_id),
items,
icons,
names,
commands,
),
// Our window carries the spell we cast on theirs (`0x4c02b7`, side 0).
@@ -741,6 +756,7 @@ fn snapshot(
name(trade.our.enchant_spell_id),
items,
icons,
names,
commands,
),
})
@@ -789,6 +805,7 @@ fn resignal_trade_items(
objects: Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
names: Res<NameCache>,
) {
use crate::item_watch::TradeTouch;
let touches: Vec<TradeTouch> = cursor
@@ -839,7 +856,7 @@ fn resignal_trade_items(
slot: wire_slot,
},
);
let mut row = resolve_slot(&item, &items, icons.as_deref(), &commands);
let mut row = resolve_slot(&item, &items, icons.as_deref(), &names, &commands);
row.object_flags = Some(flags);
script.replace_trade_slot(slot, row);
}
@@ -889,6 +906,7 @@ fn feed_trade(
&items,
icons.as_deref(),
spells.as_deref(),
&names,
&commands,
);
// A close is the core's, which shut the VM's window and left both offers in place; a
@@ -1249,7 +1267,13 @@ mod tests {
items.insert_template(1234, Some(sword));
items.insert_template(1235, Some(crate::items::test_template("Linen Cloth")));
let row = resolve_slot(&wire_item(1234), &items, None, &commands);
let row = resolve_slot(
&wire_item(1234),
&items,
None,
&NameCache::default(),
&commands,
);
assert_eq!((row.quality, row.inventory_type), (Some(4), 13));
// The icon is the template's display's, the question mark when that row is missing, and
// left to the getter while the template is unanswered.
@@ -1258,15 +1282,85 @@ mod tests {
Some("Interface\\Icons\\INV_Misc_QuestionMark")
);
assert_eq!(
resolve_slot(&wire_item(1236), &items, None, &commands).texture,
resolve_slot(
&wire_item(1236),
&items,
None,
&NameCache::default(),
&commands
)
.texture,
None
);
let row = resolve_slot(&wire_item(1235), &items, None, &commands);
let row = resolve_slot(
&wire_item(1235),
&items,
None,
&NameCache::default(),
&commands,
);
assert_eq!((row.quality, row.inventory_type), (Some(1), 0));
let row = resolve_slot(&wire_item(1236), &items, None, &commands);
let row = resolve_slot(
&wire_item(1236),
&items,
None,
&NameCache::default(),
&commands,
);
assert_eq!((row.quality, row.inventory_type), (None, 0));
}
/// A wrapped gift's giver rides both columns: the partner's block carries the wire's wrapped
/// word and gift creator (`0x534559`, `0x534574`), our placed item's object the same; only a
/// wrapped item asks for the name, a zero guid asks nothing, and a name in flight is `None`.
#[test]
fn a_wrapped_gifts_giver_reaches_both_columns() {
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut items = Items::default();
items.insert_template(1234, Some(crate::items::test_template("Gift")));
let mut names = NameCache::default();
names.insert_player(0x7, "Santa".to_string(), None);
let gift = |wrapped, giver| TradeItem {
wrapped,
gift_creator: giver,
..wire_item(1234)
};
let mut s = TradeSession::default();
s.begin(0x9);
s.open_window();
s.place_own_item(1, gift(true, 0x7), 0x8);
s.place_own_item(2, gift(false, 0x7), 0);
s.place_own_item(3, gift(true, 0), 0x8);
s.place_own_item(4, gift(true, 0x8), 0x8);
s.their.slots[0] = Some(gift(true, 0x7));
s.their.slots[1] = Some(gift(false, 0x7));
let state = snapshot(&s, &items, None, None, &names, &commands).expect("open");
let giver = |side: &TradeSideState, i: usize| {
let row = side.slots[i].as_ref().expect("filled");
(row.wrapped, row.gift_creator.clone())
};
assert_eq!(giver(&state.target, 0), (true, Some("Santa".into())));
assert_eq!(
giver(&state.target, 1),
(false, None),
"unwrapped asks nothing"
);
assert_eq!(giver(&state.player, 0), (true, Some("Santa".into())));
assert_eq!(giver(&state.player, 1), (false, None));
assert_eq!(
giver(&state.player, 2),
(true, None),
"a zero guid names no one"
);
assert_eq!(giver(&state.player, 3), (true, None), "a name in flight");
let asked: Vec<_> = rx.try_iter().collect();
assert!(
matches!(asked.as_slice(), [ClientCommand::NameQuery { guid: 0x8 }]),
"only the wrapped, nonzero, uncached giver is asked: {asked:?}"
);
}
#[test]
fn open_close_state_machine() {
let mut s = TradeSession::default();
@@ -2900,6 +2994,48 @@ mod tests {
assert!(seen(&mut app).is_empty());
}
/// The placed item is a live object: its WRAPPED flag and `ITEM_FIELD_GIFTCREATOR` reach the
/// slot when the watchers place it again (`0x52b789`, `0x52e32a`), the giver by name.
#[test]
fn a_wrapped_offer_placed_again_carries_its_giver() {
use benilla_protocol::field::FIELD_ITEM_STACK_COUNT;
use benilla_protocol::ObjectFields;
let (mut app, _rx) = resignal_app();
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(0x77, "Santa".to_string(), None);
let linen = app.world().resource::<GuidIndex>().0[&LINEN];
// ITEM_FIELD_GIFTCREATOR is the guid at fields 12 and 13, ITEM_FIELD_FLAGS field 21.
app.world_mut()
.get_mut::<ObjectStore>(linen)
.unwrap()
.0
.merge(ObjectFields::from_pairs(&[
(FIELD_ITEM_STACK_COUNT, 2),
(12, 0x77),
(13, 0),
(21, 0x8),
]));
notify(
&mut app,
crate::item_watch::ItemNotice::StackCount {
guid: LINEN,
entry: 2589,
},
);
let slots = app
.world()
.non_send_resource::<UiScript>()
.trade_player_slots()
.expect("open trade");
let row = slots[0].as_ref().expect("the slot is filled");
assert_eq!(row.object_flags, Some(0x8));
assert_eq!(
(row.wrapped, row.gift_creator.as_deref()),
(true, Some("Santa"))
);
}
/// `0x4bfb60` from the stack and enchantment watchers: the same item placed again from its
/// position, the slot showing the live count, and one signal; a placement of an item that
/// is no longer an object sends and signals nothing (`0x5d43da`).
+2
View File
@@ -43,6 +43,8 @@ fn item(item_id: u32, name: &str, count: u32, quality: u32) -> TradeSlotItem {
inventory_type: 0,
lock_id: 0,
object_flags: None,
wrapped: false,
gift_creator: None,
enchantment: None,
link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
}
+12 -2
View File
@@ -62,13 +62,23 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option<String> {
/// The column's raw dword is the offset, as the client reads it, whatever the schema typed it: the
/// other locales' columns are often left untyped.
pub(crate) fn loc_str(rs: &RecordSet, r: &Record, first: usize, locale: Locale) -> Option<String> {
loc_str_raw(rs, r, first, locale).filter(|s| !s.is_empty())
}
/// [`loc_str`] for a reader that tells an empty name from no column: `Some("")` is a name the
/// file leaves blank, `None` a column or offset that cannot be read.
pub(crate) fn loc_str_raw(
rs: &RecordSet,
r: &Record,
first: usize,
locale: Locale,
) -> Option<String> {
let off = match r.get_value(first + locale.index())? {
Value::StringRef(StringRef(off)) | Value::UInt32(off) => *off,
Value::Int32(off) => *off as u32,
Value::Float32(f) => f.to_bits(),
};
let s = rs.get_string(StringRef(off)).ok()?;
(!s.is_empty()).then(|| s.to_string())
rs.get_string(StringRef(off)).ok().map(|s| s.to_string())
}
/// `SpellIcon.dbc`: id → extensionless texture path (`Interface\Icons\…`).
+58 -6
View File
@@ -10,7 +10,7 @@ use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{loc_str, parse, str_at, u32_at};
use crate::dbc::{loc_str_raw, parse, str_at, u32_at};
const LOCK_TYPE: &str = "DBFilesClient\\LockType.dbc";
/// The file's column count, which `benilla-dbc` checks against the header.
@@ -34,9 +34,19 @@ impl LockTypeCatalog {
}
/// A lock kind's `Name` ("Herbalism"), filled into "Requires %s" (`0x5f34f9`); the caller
/// supplies the reference's `"UNKNOWN"` for a missing row.
/// supplies the reference's `"UNKNOWN"` for a missing row, and a blank name is none here.
pub fn name(&self, lock_type_id: u32) -> Option<&str> {
self.names.get(&lock_type_id).map(String::as_str)
self.names
.get(&lock_type_id)
.map(String::as_str)
.filter(|n| !n.is_empty())
}
/// Every row's `Name`, by `LockType` index, a blank one included: the item tooltip answers
/// `UNKNOWN` only for a row that is missing (`0x52bede`..`0x52bef2`), else the name as it is,
/// blank too (`0x52bef4`).
pub fn names(&self) -> impl Iterator<Item = (u32, &str)> {
self.names.iter().map(|(&id, name)| (id, name.as_str()))
}
pub fn len(&self) -> usize {
@@ -67,8 +77,11 @@ pub fn load_lock_type_catalog(chain: &mut Chain) -> Result<LockTypeCatalog> {
let bytes = chain
.read_file(LOCK_TYPE)
.with_context(|| format!("reading {LOCK_TYPE}"))?;
let rs = parse(&bytes, schema(), "LockType.dbc")?;
let locale = chain.locale();
catalog_from_bytes(&bytes, chain.locale())
}
fn catalog_from_bytes(bytes: &[u8], locale: crate::Locale) -> Result<LockTypeCatalog> {
let rs = parse(bytes, schema(), "LockType.dbc")?;
let mut cursors = HashMap::new();
let mut names = HashMap::new();
for r in rs.records() {
@@ -76,7 +89,7 @@ pub fn load_lock_type_catalog(chain: &mut Chain) -> Result<LockTypeCatalog> {
if let Some(name) = str_at(&rs, r, CURSOR_NAME_FIELD) {
cursors.insert(id, name);
}
if let Some(name) = loc_str(&rs, r, NAME_FIELD, locale) {
if let Some(name) = loc_str_raw(&rs, r, NAME_FIELD, locale) {
names.insert(id, name);
}
}
@@ -87,6 +100,45 @@ pub fn load_lock_type_catalog(chain: &mut Chain) -> Result<LockTypeCatalog> {
mod tests {
use super::*;
/// Two WDBC rows of the shipped shape: type 1 named, type 2 with a blank name column.
fn synthetic() -> Vec<u8> {
let strings = b"\0Pick Lock\0".to_vec();
let mut bytes = b"WDBC".to_vec();
for w in [
2,
LOCK_TYPE_FIELDS as u32,
LOCK_TYPE_FIELDS as u32 * 4,
strings.len() as u32,
] {
bytes.extend_from_slice(&w.to_le_bytes());
}
for (id, name_at) in [(1u32, 1u32), (2, 0)] {
let mut row = vec![0u32; LOCK_TYPE_FIELDS];
row[0] = id;
row[NAME_FIELD] = name_at;
for f in row {
bytes.extend_from_slice(&f.to_le_bytes());
}
}
bytes.extend_from_slice(&strings);
bytes
}
/// A blank `Name` is a row with no word, not a missing row: the item tooltip answers `UNKNOWN`
/// only for a missing row (`0x52bede`..`0x52bef2`) and the empty string for a blank one
/// (`0x52bef4`), so [`LockTypeCatalog::names`] lists it; the toast's [`LockTypeCatalog::name`]
/// still reads it as none.
#[test]
fn a_blank_name_is_a_row_not_a_missing_one() {
let locale = crate::Locale::from_index(0).unwrap();
let cat = catalog_from_bytes(&synthetic(), locale).unwrap();
let mut names: Vec<_> = cat.names().collect();
names.sort();
assert_eq!(names, vec![(1, "Pick Lock"), (2, "")]);
assert_eq!(cat.name(1), Some("Pick Lock"));
assert_eq!(cat.name(2), None);
}
/// The 5875 rows: a column slip lands on another column and fails.
#[test]
fn real_lock_types_name_their_cursors() {
@@ -36,6 +36,11 @@ impl ObjectFields {
pub fn item_creator(&self) -> Option<u64> {
self.get_guid(10).filter(|&g| g != 0)
}
/// `ITEM_FIELD_GIFTCREATOR` (field 12, `[item+0x114]+0x18` at `0x52e32a`): who wrapped the
/// item, read only while it carries the WRAPPED flag.
pub fn item_gift_creator(&self) -> Option<u64> {
self.get_guid(12).filter(|&g| g != 0)
}
/// `ITEM_FIELD_ITEM_TEXT_ID` (field 45, owner only): a mailed letter copy's text; nonzero
/// makes a right-click read it (`CMSG_ITEM_TEXT_QUERY`) instead of using the item.
pub fn item_text_id(&self) -> Option<u32> {
@@ -213,6 +213,8 @@ pub struct InvSlotView {
pub equip_slots: Vec<u8>,
/// The resolved `ITEM_FIELD_CREATOR` name, the tooltip's "<Made by %s>" line.
pub creator: Option<String>,
/// The resolved `ITEM_FIELD_GIFTCREATOR` name, the tooltip's `ITEM_WRAPPED_BY` line.
pub gift_creator: Option<String>,
/// The resolved enchant slots in slot order. An inspected player's record has all 7 too, but
/// vmangos fills only PERM and TEMP (`Player.cpp:10594-10595`).
pub enchants: Vec<super::EnchantView>,
@@ -538,6 +540,7 @@ impl InvSlotView {
link: slot.link.clone(),
guid: slot.guid,
creator: slot.creator.clone(),
gift_creator: slot.gift_creator.clone(),
enchants: slot.enchants.clone(),
// The item's own flags (UNLOCKED, WRAPPED) and expiry, which the tooltip builder reads.
flags: slot.flags,
@@ -72,6 +72,9 @@ pub struct ContainerSlot {
pub readable: bool,
/// The `ITEM_FIELD_CREATOR` name for the "Written by" or "<Made by>" line, once resolved.
pub creator: Option<String>,
/// The `ITEM_FIELD_GIFTCREATOR` name for a wrapped item's `ITEM_WRAPPED_BY` line, once
/// resolved; only a wrapped item asks for it, as the gift arm does (`0x52e29d`).
pub gift_creator: Option<String>,
/// `ITEM_FIELD_FLAGS`; the tooltip reads UNLOCKED `0x4` and WRAPPED `0x8`.
pub flags: u32,
/// Bound at runtime (`0x5da2c0`): `ITEM_FIELD_FLAGS & 1`, or an enchant whose
@@ -1059,6 +1062,7 @@ mod tests {
spell_0: false,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
},
@@ -1757,6 +1761,7 @@ mod tests {
spell_0: false,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
},
+1
View File
@@ -1030,6 +1030,7 @@ mod tests {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -375,6 +375,7 @@ mod tests {
guid: 0x10c,
equip_slots: vec![1],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -399,6 +400,7 @@ mod tests {
guid: 0x10d,
equip_slots: vec![11, 12], // either finger: the doll-to-doll swap
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -423,6 +425,7 @@ mod tests {
guid: 0x10e,
equip_slots: vec![19],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -456,6 +459,7 @@ mod tests {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -615,6 +619,7 @@ mod tests {
guid: 0x110,
equip_slots: vec![20, 21, 22, 23],
creator: None,
gift_creator: None,
enchants: Vec::new(),
spell_0: false,
});
@@ -660,6 +665,7 @@ mod tests {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
+77 -19
View File
@@ -1,8 +1,10 @@
//! The item tooltip's lock check, the reference's `0x5da5e0`: whether the active player can open
//! an item's lock, and by what. It colours the `LOCKED` line (`0x52be0c`..`0x52be3d`).
//! an item's lock, and by what. It colours the `LOCKED` line (`0x52be0c`..`0x52be3d`) and decides
//! the `LOCKED_WITH_SPELL_KNOWN` line under it (`0x52be98`).
//!
//! The engine reads no DBCs, so the app pushes the `Lock.dbc` rows ([`LockSlotView`]) once and the
//! player's means of opening ([`LockOpeners`]) on change; the walk is here.
//! The engine reads no DBCs, so the app pushes the `Lock.dbc` rows ([`LockSlotView`]) once, the
//! `LockType.dbc` names once and the player's means of opening ([`LockOpeners`]) on change; the
//! walk is here.
use std::collections::HashSet;
@@ -10,7 +12,8 @@ use super::UiScript;
/// One `Lock.dbc` slot as `0x5da5e0` reads it: the key type at `lock+0x4`, the index at `+0x24` and
/// the skill at `+0x44`, each 8 wide. Its action column (`+0x64`) is not read: unlike the game
/// object walk (`0x5f83d0`), an item's lock has no state to gate a slot on.
/// object walk (`0x5f83d0`), an item's lock has no state to gate a slot on. A row keeps its empty
/// slots, since the sub-line under `LOCKED` reads the first slot's type whatever it is (`0x52be5d`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LockSlotView {
/// 0 empty, 1 a key item, 2 a skill.
@@ -54,60 +57,115 @@ pub enum LockCheck {
Open,
}
/// What `0x5da5e0` reports through its out parameters and return value.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(in crate::script) struct LockWalk {
/// The return value is nonzero (`0x5da64e`, `0x5da725`): the lock has a key or skill slot and
/// none opened it.
pub closed: bool,
/// A held key opened it: the walk set the item out parameter (`0x5da78f`).
pub key_held: bool,
/// The spell out parameters (`*a1` nonzero, `*a2` points, `*a3` required, `0x5da6cc`..
/// `0x5da6f4`) of the last known open-lock spell the walk tried on a skill slot of its lock
/// type, whether or not its skill reached the requirement. A held key overwrites `*a1` with
/// its use spell (`0x5da781`; `0x5d8c80` returns one when the key has one), so this clears:
/// the colour call ignores `*a1` once `*a4` is set (`0x52be1c`), and no `Lock.dbc` row has a
/// key slot after a skill slot, so the second walk never loses a spell to a key.
pub spell: Option<SpellTry>,
}
/// One open-lock spell tried against one skill slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::script) struct SpellTry {
/// The slot's requirement.
pub required: i32,
/// The spell's skill for the player.
pub points: i32,
}
/// `0x5da5e0(lock, level)`. `level` is the item record's `+0x38`, the fallback behind a slot whose
/// skill is 0. `openers` is `None` with no active player, which opens everything.
/// skill is 0. `openers` is `None` with no active player, which opens everything and tells
/// nothing (`0x5da60b`).
///
/// Walks the slots in order: a skill slot (type 2) scans the known spells for an open-lock effect
/// whose misc value is its index and whose points reach its requirement (`0x5da6f8 jge`); a key
/// slot (type 1) looks for the item. The first to succeed ends the walk. A slot of any other type
/// is skipped. A type 1 or 2 slot makes the lock closed unless one succeeds (`0x5da64e`,
/// `0x5da725`).
pub fn check_lock(slots: &[LockSlotView], level: u32, openers: Option<&LockOpeners>) -> LockCheck {
pub(in crate::script) fn walk_lock(
slots: &[LockSlotView],
level: u32,
openers: Option<&LockOpeners>,
) -> LockWalk {
let Some(openers) = openers else {
return LockCheck::Open;
return LockWalk::default();
};
let mut closed = false;
let mut walk = LockWalk::default();
for slot in slots {
match slot.key_type {
2 => {
closed = true;
walk.closed = true;
let required = if slot.skill != 0 {
slot.skill as i32
} else {
level.wrapping_mul(5) as i32
};
for spell in &openers.spells {
if spell.lock_type == slot.index && spell.points >= required {
return LockCheck::Skill {
if spell.lock_type == slot.index {
walk.spell = Some(SpellTry {
required,
points: spell.points,
};
});
if spell.points >= required {
walk.closed = false;
return walk;
}
}
}
}
1 => {
closed = true;
walk.closed = true;
if openers.held.contains(&slot.index) {
return LockCheck::Open;
return LockWalk {
closed: false,
key_held: true,
spell: None,
};
}
}
_ => {}
}
}
if closed {
LockCheck::Closed
} else {
LockCheck::Open
walk
}
/// `0x5da5e0` read the way the `LOCKED` line's colour reads it (`0x52be11`..`0x52be3d`).
pub fn check_lock(slots: &[LockSlotView], level: u32, openers: Option<&LockOpeners>) -> LockCheck {
let walk = walk_lock(slots, level, openers);
match (walk.closed, walk.key_held, walk.spell) {
(true, _, _) => LockCheck::Closed,
(false, false, Some(SpellTry { required, points })) => {
LockCheck::Skill { required, points }
}
_ => LockCheck::Open,
}
}
impl UiScript {
/// Pushes `Lock.dbc` whole, by lock id, once at load: a clicked tooltip cannot repaint on a late
/// answer. The reference reads its loaded store (`0xc0dae4`).
/// answer. The reference reads its loaded store (`0xc0dae4`), and a lock id without a row is no
/// lock to the item builder (`0x52bd74`).
pub fn set_lock_rows(&mut self, rows: std::collections::HashMap<u32, Vec<LockSlotView>>) {
self.model_mut().lock_rows = rows;
}
/// Pushes `LockType.dbc`'s names, by index, once at load: the `LOCKED_WITH_SPELL_KNOWN` line
/// names the lock kind off the store `0xc0dad0` (`0x52bef4`); an index without a row reads
/// `UNKNOWN` (`0x52bf00`).
pub fn set_lock_type_names(&mut self, names: std::collections::HashMap<u32, String>) {
self.model_mut().lock_type_names = names;
}
/// Pushes what the active player opens locks with, on change. Never pushed, there is no active
/// player and every lock reads as open.
pub fn set_lock_openers(&mut self, openers: LockOpeners) {
+4 -1
View File
@@ -993,9 +993,11 @@ pub(crate) struct Model {
pub(crate) player_req: PlayerReqState,
/// `Lock.dbc` by lock id, pushed whole at startup, and what the active player opens locks with
/// (`None` before the first push: no active player). The item tooltip's `LOCKED` colour walks
/// them ([`super::lock_check::check_lock`]).
/// them ([`super::lock_check::check_lock`]); `LockType.dbc`'s names by index name its
/// `LOCKED_WITH_SPELL_KNOWN` line.
pub(crate) lock_rows: HashMap<u32, Vec<super::LockSlotView>>,
pub(crate) lock_openers: Option<super::LockOpeners>,
pub(crate) lock_type_names: HashMap<u32, String>,
/// Spell id to its tooltip view, and the misses asked for.
pub(crate) spell_tooltips: HashMap<u32, super::SpellTooltipView>,
pub(crate) spell_tooltip_asks: HashSet<u32>,
@@ -1684,6 +1686,7 @@ impl Model {
player_req: PlayerReqState::default(),
lock_rows: HashMap::new(),
lock_openers: None,
lock_type_names: HashMap::new(),
spell_tooltips: HashMap::new(),
spell_tooltip_asks: HashSet::new(),
pet_spell_tooltips: HashMap::new(),
@@ -86,6 +86,7 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
cooldown: None,
readable: false,
creator: None,
gift_creator: None,
flags: 0,
enchants: Vec::new(),
spell_0: false,
@@ -223,6 +224,7 @@ fn set_inventory_item_name_only_is_the_trimmed_build() {
..Default::default()
});
s.set_inventory_slots(inv);
s.set_lock_rows(HashMap::from([(7, Vec::new())]));
s.set_item_template(
8100,
ItemTemplateView {
@@ -790,6 +792,7 @@ fn locked_is_drawn_for_a_locked_object_and_sets_the_builder_flag() {
slots: HashMap::from([(1, slot(0)), (2, slot(0x4))]),
}),
);
s.set_lock_rows(HashMap::from([(7, Vec::new())]));
s.set_item_template(
8300,
ItemTemplateView {
@@ -854,6 +857,7 @@ fn bank_vault_items_keep_their_flags_and_expiry() {
]),
}),
);
s.set_lock_rows(HashMap::from([(7, Vec::new())]));
s.set_item_template(
8300,
ItemTemplateView {
@@ -580,6 +580,7 @@ fn instance_tail_creator_and_readable() {
..Default::default()
},
);
s.set_lock_rows(HashMap::from([(7, Vec::new())]));
s.set_item_template(
2589,
ItemTemplateView {
@@ -690,6 +691,187 @@ fn instance_tail_creator_and_readable() {
assert!(s.take_errors().is_empty());
}
/// A wrapped item (`ITEM_FIELD_FLAGS & 0x8`, `0x52b789`) takes the gift arm (`0x52e1b9`): it names
/// its giver off `ITEM_FIELD_GIFTCREATOR` in white (`0x52e2cb`) and never prints the creator's
/// line. A giver not yet resolved adds nothing, and an unwrapped item ignores the field.
#[test]
fn a_wrapped_item_names_its_giver_and_hides_the_creator_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"ITEM_WRAPPED_BY = "[WRAPPED_BY %s]""#).unwrap();
s.set_item_template(
5042,
ItemTemplateView {
name: "Gift".into(),
quality: 1,
..Default::default()
},
);
let slot = |flags: u32, creator: Option<&str>, gift: Option<&str>| ContainerSlot {
item_id: 5042,
count: 1,
quality: Some(1),
creator: creator.map(str::to_string),
gift_creator: gift.map(str::to_string),
flags,
..Default::default()
};
let mut slots = HashMap::new();
slots.insert(1, slot(0x8, Some("Geoffrey"), Some("Santa"))); // wrapped, giver known
slots.insert(2, slot(0x8, Some("Geoffrey"), None)); // wrapped, giver zero or in flight
slots.insert(3, slot(0, Some("Geoffrey"), Some("Santa"))); // unwrapped keeps its creator
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 3,
slots,
}),
);
s.run(
r#"
local a = CreateFrame("Button", "SlotG"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate")
TT:SetOwner(a, "ANCHOR_RIGHT"); TT:SetBagItem(0, 1)
"#,
)
.unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(texts, vec!["Gift", "[WRAPPED_BY Santa]"]);
assert_eq!(lines[1].1, [1.0; 4], "ITEM_WRAPPED_BY is white");
for (n, want) in [
(2, vec!["Gift"]),
(3, vec!["Gift", "|cff00ff00[CREATED_BY Geoffrey]|r"]),
] {
s.run(&format!(
r#"TT:SetOwner(getglobal("SlotG"), "ANCHOR_RIGHT"); TT:SetBagItem(0, {n})"#
))
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(texts, want, "slot {n}");
}
// A worn wrapped item reaches the same arm through the doll's view.
let mut inv: InventorySlots = Default::default();
inv[16] = Some(InvSlotView {
item_id: 5042,
name: Some("Gift".into()),
quality: 1,
flags: 0x8,
creator: Some("Geoffrey".into()),
gift_creator: Some("Santa".into()),
..Default::default()
});
s.set_inventory_slots(inv);
s.run(r#"TT:SetOwner(getglobal("SlotG"), "ANCHOR_RIGHT"); TT:SetInventoryItem("player", 16)"#)
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(texts, vec!["Gift", "[WRAPPED_BY Santa]"]);
assert!(s.take_errors().is_empty());
}
/// A wrapped gift in a trade names its giver on both sides. The player's slot is a live item
/// object: its WRAPPED flag takes the gift arm (`0x52b789`) and the object's `ITEM_FIELD_GIFTCREATOR`
/// names the giver, whether the slot came from a click or from the app. The partner's is a block:
/// its wrapped word (`0x534559`) takes the same arm and its gift creator (`0x534574`) names the
/// giver. A block whose wrapped word is clear ignores the giver, and an unresolved one adds nothing.
#[test]
fn a_wrapped_gift_in_trade_names_its_giver_on_both_sides() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"ITEM_WRAPPED_BY = "[WRAPPED_BY %s]" ITEM_CREATED_BY = "[CREATED_BY %s]""#)
.unwrap();
s.set_item_template(
5042,
ItemTemplateView {
name: "Gift".into(),
quality: 1,
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "SlotT"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate")
"#,
)
.unwrap();
let row = |object_flags: Option<u32>, wrapped: bool, gift: Option<&str>| TradeSlotItem {
item_id: 5042,
name: Some("Gift".into()),
count: 1,
quality: Some(1),
object_flags,
wrapped,
gift_creator: gift.map(str::to_string),
..Default::default()
};
let mut st = TradeState::default();
st.player.slots[0] = Some(row(Some(0x8), true, Some("Santa")));
st.player.slots[1] = Some(row(Some(0), false, Some("Santa")));
st.target.slots[0] = Some(row(None, true, Some("Santa")));
st.target.slots[1] = Some(row(None, false, Some("Santa")));
st.target.slots[2] = Some(row(None, true, None));
s.set_trade(Some(st));
let texts = |s: &mut UiScript, call: &str| -> Vec<String> {
s.run(&format!("TT:SetOwner(SlotT, 'ANCHOR_RIGHT') {call}"))
.unwrap();
lines_of(s).into_iter().map(|(t, _)| t).collect()
};
let gift = vec!["Gift".to_string(), "[WRAPPED_BY Santa]".to_string()];
assert_eq!(texts(&mut s, "TT:SetTradePlayerItem(1)"), gift, "player");
assert_eq!(texts(&mut s, "TT:SetTradeTargetItem(1)"), gift, "target");
assert_eq!(
texts(&mut s, "TT:SetTradePlayerItem(2)"),
vec!["Gift"],
"unwrapped"
);
assert_eq!(
texts(&mut s, "TT:SetTradeTargetItem(2)"),
vec!["Gift"],
"unwrapped block"
);
assert_eq!(
texts(&mut s, "TT:SetTradeTargetItem(3)"),
vec!["Gift"],
"giver unresolved"
);
// The player's click takes the giver from the bag record it places.
s.open_trade_window();
s.set_trade(Some(TradeState::default()));
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 1,
slots: HashMap::from([(
1,
ContainerSlot {
item_id: 5042,
guid: 0x5042,
count: 1,
quality: Some(1),
link: Some("|cffffffff|Hitem:5042:0:0:0|h[Gift]|h|r".into()),
creator: Some("Geoffrey".into()),
gift_creator: Some("Santa".into()),
flags: 0x8,
..Default::default()
},
)]),
}),
);
s.run("PickupContainerItem(0, 1) ClickTradeButton(1)")
.unwrap();
assert_eq!(
texts(&mut s, "TT:SetTradePlayerItem(1)"),
gift,
"placed by a click"
);
assert!(s.take_errors().is_empty());
}
/// Bonding `[record+0x194]` in 1..5 decides whether a bind line prints; a runtime-bound instance
/// (`0x5da2c0`: the soulbound flag, or a live enchant slot that binds) prints `ITEM_SOULBOUND`,
/// or `ITEM_BIND_QUEST` for the quest kinds; else the jump table `0x52e4fc` picks 1 picked up,
@@ -37,7 +37,21 @@ fn key_slot(entry: u32) -> LockSlotView {
/// A locked box (item level 20) in the backpack, whose lock is `slots`, with the player's means
/// of opening it `openers`.
fn fixture(slots: Vec<LockSlotView>, openers: Option<LockOpeners>) -> UiScript {
fixture_rows(LOCK_ID, vec![(LOCK_ID, slots)], openers)
}
/// [`fixture`] with the box's template naming lock `template_lock` and the store holding `rows`.
fn fixture_rows(
template_lock: u32,
rows: Vec<(u32, Vec<LockSlotView>)>,
openers: Option<LockOpeners>,
) -> UiScript {
let mut s = item_script();
s.run(
r#"LOCKED_WITH_ITEM = "[LOCKED_WITH_ITEM %s]"
LOCKED_WITH_SPELL_KNOWN = "[LOCKED_WITH_SPELL_KNOWN %s]""#,
)
.unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
@@ -53,7 +67,7 @@ fn fixture(slots: Vec<LockSlotView>, openers: Option<LockOpeners>) -> UiScript {
name: "Lockbox".into(),
quality: 1,
item_level: 20,
lock_id: LOCK_ID,
lock_id: template_lock,
..Default::default()
},
);
@@ -74,7 +88,8 @@ fn fixture(slots: Vec<LockSlotView>, openers: Option<LockOpeners>) -> UiScript {
)]),
}),
);
s.set_lock_rows(HashMap::from([(LOCK_ID, slots)]));
s.set_lock_rows(rows.into_iter().collect());
s.set_lock_type_names(HashMap::from([(1, "Pick Lock".to_string())]));
if let Some(o) = openers {
s.set_lock_openers(o);
}
@@ -175,3 +190,262 @@ fn the_first_slot_to_open_the_lock_decides_the_colour() {
let mut s = fixture(vec![skill_slot(1, 25), key_slot(KEY)], Some(strong));
assert_eq!(locked_color(&mut s), ORANGE, "the skill is first");
}
/// The tooltip's lines under a bag hover.
fn bag_lines(s: &mut UiScript) -> Vec<(String, [f32; 4])> {
s.run("TT:SetOwner(Slot, 'ANCHOR_RIGHT') TT:SetBagItem(0, 1)")
.unwrap();
lines_of(s)
}
fn line<'a>(lines: &'a [(String, [f32; 4])], text: &str) -> Option<&'a (String, [f32; 4])> {
lines.iter().find(|(t, _)| t == text)
}
fn key_template(s: &mut UiScript) {
s.set_item_template(
KEY,
ItemTemplateView {
name: "Skeleton Key".into(),
quality: 1,
..Default::default()
},
);
}
/// A key slot (`0x52bf30`) names the key from the item cache in white (`0x52bf93`), under LOCKED,
/// whether or not the player holds it.
#[test]
fn a_key_lock_names_its_key_in_white_under_locked() {
for held in [false, true] {
let mut openers = LockOpeners::default();
if held {
openers.held.insert(KEY);
}
let mut s = fixture(vec![key_slot(KEY)], Some(openers));
key_template(&mut s);
let lines = bag_lines(&mut s);
let at = |t: &str| lines.iter().position(|(l, _)| l == t);
assert_eq!(
at("[LOCKED_WITH_ITEM Skeleton Key]"),
at("[LOCKED]").map(|i| i + 1),
"{lines:?}"
);
assert_eq!(
line(&lines, "[LOCKED_WITH_ITEM Skeleton Key]").unwrap().1,
[1.0; 4]
);
}
}
/// A key the item cache lacks adds nothing (`0x52bf67`) and is asked for, so the hover's repaint
/// draws it on arrival.
#[test]
fn a_key_the_cache_lacks_adds_no_line_and_is_asked_for() {
let mut s = fixture(vec![key_slot(KEY)], Some(LockOpeners::default()));
let lines = bag_lines(&mut s);
assert!(line(&lines, "[LOCKED]").is_some());
assert!(
lines.iter().all(|(t, _)| !t.starts_with("[LOCKED_WITH")),
"{lines:?}"
);
assert!(s.take_item_stat_asks().contains(&KEY));
}
/// A skill slot names the lock type, in the ramp colour of the spell the walk tried last
/// (`0x52beae`), the same colour `LOCKED` takes when the spell reaches the requirement.
#[test]
fn a_skill_lock_names_its_lock_type_in_the_ramp_colour() {
for (points, want) in [(25, ORANGE), (50, YELLOW), (75, LOCK_GREEN), (125, GRAY)] {
let mut s = fixture(vec![skill_slot(1, 25)], Some(picker(points)));
let lines = bag_lines(&mut s);
let sub = line(&lines, "[LOCKED_WITH_SPELL_KNOWN Pick Lock]")
.unwrap_or_else(|| panic!("no sub-line at {points}: {lines:?}"));
assert_eq!(sub.1, want, "{points} points");
assert_eq!(line(&lines, "[LOCKED]").unwrap().1, want);
}
}
/// The line shows for any known opener of the lock type, even one whose skill falls short
/// (`0x5da6cc` records it before the compare): red, under a red `LOCKED`.
#[test]
fn a_known_opener_below_the_skill_still_names_the_lock_type_in_red() {
let mut s = fixture(vec![skill_slot(1, 25)], Some(picker(24)));
let lines = bag_lines(&mut s);
assert_eq!(line(&lines, "[LOCKED]").unwrap().1, RED);
assert_eq!(
line(&lines, "[LOCKED_WITH_SPELL_KNOWN Pick Lock]")
.unwrap()
.1,
RED
);
}
/// No known spell of the lock type, or no active player (the walk returns before it reads a
/// slot, `0x5da60b`), adds nothing (`0x52bea2`).
#[test]
fn a_skill_lock_with_no_known_opener_adds_no_line() {
for openers in [
Some(LockOpeners::default()),
// A herbalism spell against a lockpicking lock.
Some(LockOpeners {
spells: vec![LockOpener {
lock_type: 2,
points: 300,
}],
held: HashSet::new(),
}),
None,
] {
let mut s = fixture(vec![skill_slot(1, 25)], openers.clone());
let lines = bag_lines(&mut s);
assert!(line(&lines, "[LOCKED]").is_some());
assert!(
lines.iter().all(|(t, _)| !t.starts_with("[LOCKED_WITH")),
"{openers:?}: {lines:?}"
);
}
}
/// A lock type the store lacks reads `UNKNOWN` (`0x52bf00`); the line is the first slot's alone,
/// so a lock whose first slot is empty adds none (`0x52be5d`).
#[test]
fn the_sub_line_reads_the_first_slot_and_names_a_missing_type_unknown() {
let mut s = fixture(
vec![skill_slot(9, 25)],
Some(LockOpeners {
spells: vec![LockOpener {
lock_type: 9,
points: 25,
}],
held: HashSet::new(),
}),
);
let lines = bag_lines(&mut s);
assert!(
line(&lines, "[LOCKED_WITH_SPELL_KNOWN UNKNOWN]").is_some(),
"{lines:?}"
);
let mut s = fixture(
vec![LockSlotView::default(), skill_slot(1, 25)],
Some(picker(300)),
);
let lines = bag_lines(&mut s);
assert!(line(&lines, "[LOCKED]").is_some());
assert!(
lines.iter().all(|(t, _)| !t.starts_with("[LOCKED_WITH")),
"{lines:?}"
);
}
/// A record whose lock id has no row falls back to the block's row (`0x52bd76`): the trade
/// target's lock names the row, and `LOCKED` and its sub-line follow that row. With no row in
/// either, there is no lock to draw (`0x52bda9`).
#[test]
fn a_missing_record_row_falls_back_to_the_blocks_row() {
const MISSING: u32 = 99;
let mut s = fixture_rows(
MISSING,
vec![(LOCK_ID, vec![skill_slot(1, 25)])],
Some(picker(25)),
);
let trade = |lock_id: u32| {
let mut st = TradeState::default();
st.target.slots[0] = Some(TradeSlotItem {
item_id: LOCKBOX,
name: Some("Lockbox".into()),
count: 1,
lock_id,
..Default::default()
});
st
};
s.set_trade(Some(trade(LOCK_ID)));
s.run("TT:SetOwner(Slot, 'ANCHOR_RIGHT') TT:SetTradeTargetItem(1)")
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(line(&lines, "[LOCKED]").unwrap().1, ORANGE, "{lines:?}");
assert!(line(&lines, "[LOCKED_WITH_SPELL_KNOWN Pick Lock]").is_some());
// Neither row exists: no LOCKED, no sub-line.
s.set_trade(Some(trade(MISSING)));
s.run("TT:SetOwner(Slot, 'ANCHOR_RIGHT') TT:SetTradeTargetItem(1)")
.unwrap();
let lines = lines_of(&mut s);
assert!(line(&lines, "[LOCKED]").is_none(), "{lines:?}");
// A bag item (no block) whose record names a missing row is not locked either.
let lines = bag_lines(&mut s);
assert!(line(&lines, "[LOCKED]").is_none(), "{lines:?}");
}
/// A lock type whose name is blank is a row, not a missing one: only a missing row reads `UNKNOWN`
/// (`0x52bede`..`0x52bef2`), a blank name fills the line as it is (`0x52bef4`).
#[test]
fn a_blank_lock_type_name_fills_the_line_and_is_not_unknown() {
let mut s = fixture(vec![skill_slot(5, 25)], Some(picker_for(5, 25)));
s.set_lock_type_names(HashMap::from([(5, String::new())]));
let lines = bag_lines(&mut s);
assert!(
line(&lines, "[LOCKED_WITH_SPELL_KNOWN ]").is_some(),
"{lines:?}"
);
assert!(line(&lines, "[LOCKED_WITH_SPELL_KNOWN UNKNOWN]").is_none());
}
fn picker_for(lock_type: u32, points: i32) -> LockOpeners {
LockOpeners {
spells: vec![LockOpener { lock_type, points }],
held: HashSet::new(),
}
}
/// OPENABLE reads the record's raw lock id: a nonzero one needs the object UNLOCKED (`0x52e2fc`),
/// with no row check, so a lock id without a Lock row (no LOCKED line to draw) still withholds it.
#[test]
fn openable_waits_for_unlocked_on_a_lock_id_with_no_row() {
const MISSING: u32 = 99;
let mut s = fixture_rows(MISSING, Vec::new(), None);
s.set_item_template(
LOCKBOX,
ItemTemplateView {
name: "Lockbox".into(),
quality: 1,
flags: 0x4,
lock_id: MISSING,
..Default::default()
},
);
let slot = |flags| ContainerSlot {
item_id: LOCKBOX,
count: 1,
quality: Some(1),
flags,
..Default::default()
};
let hover = |s: &mut UiScript, flags| {
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: HashMap::from([(1, slot(flags))]),
}),
);
bag_lines(s)
};
let lines = hover(&mut s, 0);
assert!(
line(&lines, "[LOCKED]").is_none(),
"no row, no lock: {lines:?}"
);
assert!(
line(&lines, "[ITEM_OPENABLE]").is_none(),
"locked by id, not unlocked: {lines:?}"
);
let lines = hover(&mut s, 0x4);
assert!(
line(&lines, "[ITEM_OPENABLE]").is_some(),
"UNLOCKED: {lines:?}"
);
}
@@ -339,6 +339,10 @@ fn live_objects_and_blocks_reach_the_flag() {
let mut s = fixture();
s.run(r#"ITEM_DURATION_HOURS = "[DURATION %d h]" LOCKED = "[LOCKED]""#)
.unwrap();
s.set_lock_rows(std::collections::HashMap::from([
(7, Vec::new()),
(9, Vec::new()),
]));
s.set_item_template(
8300,
ItemTemplateView {
@@ -148,6 +148,7 @@ fn slot_instance(s: &crate::script::ContainerSlot, openable_source: bool) -> ren
name: s.link.as_deref().and_then(link_name).map(str::to_string),
durability: s.durability,
creator: s.creator.clone(),
gift_creator: s.gift_creator.clone(),
has_text: s.readable,
flags: s.flags,
already_bound: s.already_bound,
@@ -157,6 +158,7 @@ fn slot_instance(s: &crate::script::ContainerSlot, openable_source: bool) -> ren
duration_ms: s.duration_ms,
object: true,
block_lock_id: 0,
block_wrapped: false,
cooldown_ms: 0,
}
}
@@ -182,6 +184,7 @@ pub(super) fn owned_instance(model: &Model, item_id: u32) -> Option<render::Item
name: s.name.clone(),
durability: s.durability,
creator: s.creator.clone(),
gift_creator: s.gift_creator.clone(),
flags: s.flags,
already_bound: s.already_bound,
enchants: s.enchants.clone(),
@@ -451,6 +454,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
name: s.name.clone(),
durability: s.durability,
creator: s.creator.clone(),
gift_creator: s.gift_creator.clone(),
has_text: false,
flags: s.flags,
already_bound: s.already_bound,
@@ -465,6 +469,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// inspected unit's item is a block (`0x532ee0`).
object: unit.eq_ignore_ascii_case("player"),
block_lock_id: 0,
block_wrapped: false,
// The player's own item answers the cooldown query
// (`0x5331ec`), unless it is charged and spent
// (`0x53318a`-`0x53319b`, the feed's `None`); an inspected
@@ -657,10 +662,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
clear(lua, h);
let inst = render::ItemInstance {
name: item.name.clone(),
// The target side's block carries the partner's lock id (`0x534563`); the
// player's is zero (`0x534341`), but its slot is a live item object, whose
// flags the LOCKED gate reads (`0x5342ed`, `0x5343da`).
// The target side's block carries the partner's lock id (`0x534563`), wrapped
// word (`0x534559`) and gift creator (`0x534574`); the player's lock id is
// zero (`0x534341`), but its slot is a live item object, whose flags the
// LOCKED and wrapped gates read (`0x5342ed`, `0x5343da`, `0x52b789`).
block_lock_id: if player_side { 0 } else { item.lock_id },
block_wrapped: !player_side && item.wrapped,
gift_creator: item.gift_creator.clone(),
object: item.object_flags.is_some(),
flags: item.object_flags.unwrap_or(0),
..Default::default()
@@ -25,6 +25,9 @@ pub(in crate::script) struct ItemInstance {
/// The resolved `ITEM_FIELD_CREATOR` name (the reference's `0x55f080`); `None` while it is
/// unresolved, which prints no line either (`0x52e209`).
pub creator: Option<String>,
/// The resolved `ITEM_FIELD_GIFTCREATOR` name (`0x55f080` at `0x52e29d`) of a wrapped item;
/// `None` while it is unresolved (`0x52e2a4`) or the field is 0 (`0x52e288`): no line either way.
pub gift_creator: Option<String>,
/// `ITEM_FIELD_ITEM_TEXT_ID` ≠ 0, a letter's copy: the creator line reads WRITTEN_BY
/// (`0x52e223`) and READABLE shows (`0x52e348`).
pub has_text: bool,
@@ -54,6 +57,10 @@ pub(in crate::script) struct ItemInstance {
/// none or no block. With no lock row from the template it names one (`0x52bd78`..`0x52bda9`),
/// and a nonzero one draws LOCKED even without an object (`0x52bdd2`).
pub block_lock_id: u32,
/// The block's wrapped word (`[this+0x42c]`, set by the trade target's setter, `0x534559`): it
/// takes the gift arm and the no-id-source fork like an object's WRAPPED flag (`0x52b789`,
/// `0x52c991`).
pub block_wrapped: bool,
/// The remaining use cooldown in ms (`[this+0x408]`, `start + duration - now`), 0 for none. Only
/// the three setters that query the item's cooldown fill it, `SetBagItem` (`0x53488c`),
/// `SetInventoryItem` (`0x533226`) and `SetAction`'s item leg (`0x5324d5`), and each does so
@@ -61,6 +68,13 @@ pub(in crate::script) struct ItemInstance {
pub cooldown_ms: u32,
}
impl ItemInstance {
/// The wrapped test the builder runs once: the object's WRAPPED flag, or the block's word.
fn wrapped(&self) -> bool {
self.flags & 0x8 != 0 || self.block_wrapped
}
}
/// The set block's blank gold line, the reference's literal `0x854b2c`: a space and a newline. An
/// empty string would be no row, as `AddLine` (`0x530270`) drops an empty left text uncounted
/// (`0x5302a9`). `" \n"` is one row: the stepper `0x5c7470` consumes the break with the space
@@ -86,8 +100,7 @@ pub(super) struct BuilderFlags {
/// (`0x52ce0d`). The
/// setters push it as 1 or nil; the stock buttons arm `updateTooltip` from it. The LOCKED line
/// sets it on every lock path (`0x52bfa6`, after `0x52be58`, whether or not a `LOCKED_WITH_*`
/// sub-line follows), as ITEM_COOLDOWN_TIME does (`0x52e140`). Not built: ITEM_WRAPPED_BY and the
/// `LOCKED_WITH_*` sub-line.
/// sub-line follows), as ITEM_COOLDOWN_TIME does (`0x52e140`).
pub(super) fn render_view_flagged(
lua: &Lua,
this: &Table,
@@ -199,22 +212,17 @@ pub(super) fn render_view_flagged(
if v.start_quest != 0 {
keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?;
}
// LOCKED (`0x52bdde`..`0x52be58`), for a lock row the template or the block names
// (`0x52bd57`..`0x52bda9`) and a source that passes the gate (`0x52bdb3`..`0x52bdd8`): an
// item object lacking UNLOCKED `0x4`, or a block with a lock id of its own. A template
// source has neither, so it draws no LOCKED. Every lock path then sets the builder's
// return value (`0x52bfa6`).
let block_lock = inst.map_or(0, |i| i.block_lock_id);
let has_row = v.lock_id != 0 || block_lock != 0;
let passes = block_lock != 0 || inst.is_some_and(|i| i.object && i.flags & 0x4 == 0);
if has_row && passes {
// The record's row first, else the block's (`0x52bd57`, `0x52bd78`).
let row = if v.lock_id != 0 {
v.lock_id
} else {
block_lock
};
keyed("LOCKED", &[], lock_color(lua, row, v.item_level), false)?;
// LOCKED (`0x52bdde`..`0x52be58`) and the line under it. The lock row is the record's own,
// else the block's (`0x52bd57`..`0x52bda9`): a row the store lacks falls through to the
// block, and with neither there is no lock. The source must pass the gate (`0x52bdb3`..
// `0x52bdd8`): an item object lacking UNLOCKED `0x4`, or a block with a lock id of its
// own. A template source has neither, so it draws no LOCKED. Every lock path then sets
// the builder's return value (`0x52bfa6`).
if let Some(lock) = lock_lines(lua, v, inst) {
keyed("LOCKED", &[], lock.color, false)?;
if let Some(sub) = &lock.sub {
keyed(sub.key, &[Arg::S(&sub.arg)], sub.color, false)?;
}
live = true;
}
}
@@ -428,7 +436,7 @@ pub(super) fn render_view_flagged(
// No id source (`0x52c991`): a wrapped gift, or no item object and no instance block
// (`+0x440 == 0`), here a hover passing no [`ItemInstance`]. The fork tests the block's
// presence, not its contents (`0x52c9a3`), so a block source never shows the placeholder.
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
let no_id_source = inst.is_none_or(ItemInstance::wrapped);
if no_id_source && !signable && v.random_property != 0 {
keyed("ITEM_RANDOM_ENCHANT", &[], GREEN, false)?;
}
@@ -662,19 +670,23 @@ pub(super) fn render_view_flagged(
// source (`None`) stops, so a link, loot, mail or auction hover, which has an instance but no
// object, still reaches READABLE.
let Some(inst) = inst else { return Ok(live) };
// The creator line (`0x52e1b1..0x52e2db`), white `0xc0cf60`: ITEM_WRITTEN_BY for a letter, else
// ITEM_CREATED_BY, green by its own `|cff00ff00`. A wrapped instance (`0x8`, gate `0x52b7b0`)
// takes ITEM_WRAPPED_BY off `ITEM_FIELD_GIFTCREATOR` in the reference; that is not built, so a
// wrapped instance prints no creator line.
if inst.flags & 0x8 == 0 {
if let Some(name) = &inst.creator {
let key = if inst.has_text {
"ITEM_WRITTEN_BY"
} else {
"ITEM_CREATED_BY"
};
keyed(key, &[Arg::S(name)], WHITE, false)?;
// The creator line (`0x52e1b1..0x52e2db`), white `0xc0cf60`. The arm is picked once, at
// `0x52b78f`..`0x52b7b0`: an item object with WRAPPED `0x8` (`0x52b789`) takes the gift arm,
// and so does a block whose `+0x42c` is set (the trade target's). The gift arm adds
// ITEM_WRAPPED_BY off `ITEM_FIELD_GIFTCREATOR` (`0x52e32a`) or the block's `+0x418`, and never
// the creator's line. Any other source adds
// ITEM_WRITTEN_BY for a letter, else ITEM_CREATED_BY, green by its own `|cff00ff00`.
if inst.wrapped() {
if let Some(name) = &inst.gift_creator {
keyed("ITEM_WRAPPED_BY", &[Arg::S(name)], WHITE, false)?;
}
} else if let Some(name) = &inst.creator {
let key = if inst.has_text {
"ITEM_WRITTEN_BY"
} else {
"ITEM_CREATED_BY"
};
keyed(key, &[Arg::S(name)], WHITE, false)?;
}
// One green line, OPENABLE winning (`0x52e2f2..0x52e35d`). Openable: a p6=0 source and either
// the loot flag `0x4` behind its lock gate (a lock needs UNLOCKED) or a wrapped gift (`0x200`
@@ -691,21 +703,87 @@ pub(super) fn render_view_flagged(
Ok(live)
}
/// The colour of `LOCKED` for lock row `row` of an item of `item_level` (`0x52bdde`..`0x52be3d`):
/// red unless the lock check `0x5da5e0` finds an opener, then the margin ramp `0x529fa0` for a
/// known spell, else the green `0xc0d420` for a held key or a lock with no key or skill slot. A
/// row the app never pushed (no `Lock.dbc`) has nothing to open it, so it stays red.
fn lock_color(lua: &Lua, row: u32, item_level: u32) -> [f32; 4] {
/// What the lock block draws: `LOCKED`'s colour and the line under it.
struct LockLines {
color: [f32; 4],
sub: Option<LockSubLine>,
}
/// The line under `LOCKED`, `key` filled with `arg`.
struct LockSubLine {
key: &'static str,
arg: String,
color: [f32; 4],
}
/// The lock block of the item builder (`0x52bd57`..`0x52bfa6`), `None` when it draws nothing.
///
/// `LOCKED`'s colour is red unless the lock check `0x5da5e0` finds an opener, then the margin ramp
/// `0x529fa0` for a known spell, else the green `0xc0d420` for a held key or a lock with no key or
/// skill slot (`0x52bdde`..`0x52be3d`). The first slot's type then picks the line under it
/// (`0x52be5d`):
/// - a key (1) names the key item from the item cache, in white, and nothing on a miss
/// (`0x52bf30`..`0x52bfa1`); a miss asks for the template, and the line appears on a redraw,
/// which benilla runs only where stock re-runs `OnEnter` (a bag hover does, the trade
/// target's does not);
/// - a skill (2) walks the lock a second time (`0x52be98`) and names the lock type in the ramp's
/// colour for the last open-lock spell it tried, even one whose skill falls short; no known
/// opener of the type adds nothing (`0x52bea2`);
/// - anything else adds nothing.
fn lock_lines(lua: &Lua, v: &ItemTemplateView, inst: Option<&ItemInstance>) -> Option<LockLines> {
use crate::script::lock_check::walk_lock;
use crate::script::{check_lock, LockCheck};
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slots) = model.lock_rows.get(&row) else {
return RED;
let mut guard = lua.app_data_mut::<Model>().expect("model app_data");
let model = &mut *guard;
let block_lock = inst.map_or(0, |i| i.block_lock_id);
let row = if model.lock_rows.contains_key(&v.lock_id) {
v.lock_id
} else if block_lock != 0 && model.lock_rows.contains_key(&block_lock) {
block_lock
} else {
return None;
};
match check_lock(slots, item_level, model.lock_openers.as_ref()) {
let passes = block_lock != 0 || inst.is_some_and(|i| i.object && i.flags & 0x4 == 0);
if !passes {
return None;
}
let slots = &model.lock_rows[&row];
let openers = model.lock_openers.as_ref();
let color = match check_lock(slots, v.item_level, openers) {
LockCheck::Closed => RED,
LockCheck::Open => LOCK_GREEN,
LockCheck::Skill { required, points } => skill_color(required, points),
}
};
let sub =
match slots.first() {
Some(first) if first.key_type == 1 => {
let entry = first.index;
let name = model.item_templates.get(&entry).map(|t| t.name.clone());
if name.is_none() && entry != 0 {
model.item_stat_asks.insert(entry);
}
name.map(|arg| LockSubLine {
key: "LOCKED_WITH_ITEM",
arg,
color: WHITE,
})
}
Some(first) if first.key_type == 2 => walk_lock(slots, v.item_level, openers)
.spell
.map(|t| LockSubLine {
key: "LOCKED_WITH_SPELL_KNOWN",
// `UNKNOWN` (`0x838044`) only for a missing row (`0x52bede`..`0x52bef2`); a
// blank name stays blank (`0x52bef4`).
arg: model
.lock_type_names
.get(&first.index)
.cloned()
.unwrap_or_else(|| "UNKNOWN".to_string()),
color: skill_color(t.required, t.points),
}),
_ => None,
};
Some(LockLines { color, sub })
}
/// [`render_view_flagged`] for a caller that does not answer the flag.
+14
View File
@@ -38,6 +38,14 @@ pub struct TradeSlotItem {
/// slot holds a live item object, which the tooltip builder's LOCKED gate reads (`0x5342ed`);
/// the partner's side has none.
pub object_flags: Option<u32>,
/// The wire's wrapped word: the partner's block `+0x42c` (`0x534559`), which the tooltip's
/// wrapped gates read (`0x52c991`, `0x52cc5b`). The player's side reads its object's
/// `ITEM_FIELD_FLAGS` instead, so this only mirrors them there.
pub wrapped: bool,
/// The resolved name of the giver of a wrapped gift (`ITEM_FIELD_GIFTCREATOR` on the player's
/// side, the block's `+0x418` on the partner's, `0x534574`); `None` while unresolved or when
/// the guid is 0, which prints no `ITEM_WRAPPED_BY` line.
pub gift_creator: Option<String>,
/// Slot 7 only: the name of the spell being cast on the item, which stock shows in green
/// (`TradeFrame.lua:62`); `None` on every other slot and while nothing is cast.
pub enchantment: Option<String>,
@@ -477,6 +485,8 @@ fn trade_slot_item(model: &Model, item: &cursor::CursorItem) -> TradeSlotItem {
inventory_type: s.inventory_type,
lock_id: 0,
object_flags: Some(s.flags),
wrapped: s.flags & 0x8 != 0,
gift_creator: s.gift_creator.clone(),
enchantment: None,
link: s.link.clone(),
})
@@ -760,6 +770,8 @@ mod tests {
inventory_type: 0,
lock_id: 0,
object_flags: None,
wrapped: false,
gift_creator: None,
enchantment: None,
link: Some(format!(
"|cffffffff|Hitem:{item_id}:0:0:0|h[Linen Cloth]|h|r"
@@ -804,6 +816,8 @@ mod tests {
inventory_type: 0,
lock_id: 0,
object_flags: None,
wrapped: false,
gift_creator: None,
enchantment: None,
link: None,
});
+3 -3
View File
@@ -636,7 +636,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- An id outside the seven built windows raises, where the reference serves ten records and silently ignores any other id (`0x4a0be1`), because benilla keeps only the seven and every caller loops `1, NUM_CHAT_WINDOWS`. ([`src/script/chat_window.rs:220`](../crates/benilla-ui/src/script/chat_window.rs#L220))
- One past `i32::MAX` clamps where the reference keeps the low 32 bits, for the colours' reason. ([`src/script/chat_window.rs:430`](../crates/benilla-ui/src/script/chat_window.rs#L430))
- With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:364`](../crates/benilla-ui/src/script/colorselect.rs#L364))
- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:658`](../crates/benilla-ui/src/script/container.rs#L658))
- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:661`](../crates/benilla-ui/src/script/container.rs#L661))
- A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:104`](../crates/benilla-ui/src/script/craft.rs#L104))
- On `Terrain` the reference keeps a spell, action, macro or pet action on the left button; we clear them, because otherwise a spell has no left-click way off the cursor. ([`src/script/cursor.rs:523`](../crates/benilla-ui/src/script/cursor.rs#L523))
- Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:694`](../crates/benilla-ui/src/script/cursor.rs#L694))
@@ -694,8 +694,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The reference reads uninitialised fields before the seed; we answer the first legal design because that value is undefined. ([`src/script/tabard.rs:103`](../crates/benilla-ui/src/script/tabard.rs#L103))
- Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4dcd7c`), so Lua 5.0 hands back the stack top, the node argument: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/taxi.rs:138`](../crates/benilla-ui/src/script/taxi.rs#L138))
- A handler that unregisters the saved next ends the dispatch, because the reference then reads a freed node. ([`src/script/tick.rs:30`](../crates/benilla-ui/src/script/tick.rs#L30))
- While the template is in flight the reference leaves the tooltip empty (`0x52b6a3`); this shows the slot's name, because a blank plate under an on-screen name reads as broken. ([`src/script/tooltip_item/mod.rs:611`](../crates/benilla-ui/src/script/tooltip_item/mod.rs#L611))
- The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:375`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L375))
- While the template is in flight the reference leaves the tooltip empty (`0x52b6a3`); this shows the slot's name, because a blank plate under an on-screen name reads as broken. ([`src/script/tooltip_item/mod.rs:616`](../crates/benilla-ui/src/script/tooltip_item/mod.rs#L616))
- The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:383`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L383))
- The unknown-skill "Requires %s" shares it, though its reference red is `0xffff0000`, because a 32/255 difference in G and B does not show. ([`src/script/tooltip_unit.rs:30`](../crates/benilla-ui/src/script/tooltip_unit.rs#L30))
- The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:197`](../crates/benilla-ui/src/script/trainer/mod.rs#L197))
- A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:871`](../crates/benilla-ui/src/script/trainer/mod.rs#L871))