Files
benilla-octo/crates/benilla-app/src/ui_items/feed.rs
T
SamandClaude Fable 5 164b0a7a44 names persist, the GM notice is a toast, and the target reads Dead
- the name cache persists to `benilla-config/cache/<realm>.tsv`, because the reference's `namecache.wdb` / `petnamecache.wdb` do and that is why it never sees the races we lost every time — unstabling a pet raised `Usage: SetText("text")` off an unresolved `UnitName("pet")`; the header carries magic, format, build, locale and realm and any mismatch discards the file (no checksum, no TTL: eviction is explicit only, via `SMSG_INVALIDATE_PLAYER`, decoded now and never sent by vmangos), negatives persist too, written on a 10 s debounce and at exit; a stable list also seeds the pet-name cache from the names it already carries
- `SMSG_NOTIFICATION` is the red UIErrorsFrame toast, not a chat line — `.gm on` printed twice because vmangos sends both a system message and a notification and both were rendered as chat; the stand-in dated from before the errors frame existed; `SMSG_AREA_TRIGGER_MESSAGE` moves with it on the yellow arm (its handler ends `xor edx,edx` — flag 0 — where the notification's is flag 1)
- the target frame's dead text is the GlobalString `DEAD` = "Dead" resolved through `text=` like the target-of-target frame, not the key typed into `SetText` in caps, and it takes `UnitIsConnected` so a disconnected player does not read as dead; a shipped-script sweep now refuses any key-shaped literal handed to a text sink; and the Help window is one click deep — Home's button opens the ticket text box, Auto-Unstuck sits beside it, and a ticket files as category 0, which a GM's queue names "Unknown" (a guard added days earlier refused 0 on reasoning from the enum rather than from the function that reads it)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:23:05 +07:00

1532 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The inward half of the container seam (see the parent module doc): the per-frame push from the
//! self player's descriptor into the VM's `ContainerState`/`ContainerSlot` snapshots, plus the
//! shared item-tooltip stat feed the bags/paper-doll/etc. all read from.
use std::collections::HashMap;
use bevy::prelude::*;
use benilla_protocol::ItemInfo;
use benilla_ui::script::{ContainerSlot, ContainerState, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use crate::ui_script::gate;
use super::equip_error::equip_error_key;
use super::{
find_equip_slot, has_key, keyring_size, slot_guid_count, EquipErrors, BAGS, BAG_SLOT_FIRST,
BANK_BAGS, BANK_BAG_ID_FIRST, BANK_BAG_SLOT_FIRST, BANK_CONTAINER, BANK_SLOTS,
KEYRING_CONTAINER, KEYRING_SLOTS, PACK_SLOTS,
};
/// The feed's memory of what it last pushed, for per-bag change events. No cooldown-churn gate:
/// the pushed triple carries the ABSOLUTE start, which is frame-stable for a running cooldown.
#[derive(Default)]
pub(crate) struct FeedMemory {
pushed: HashMap<i64, ContainerState>,
/// The last `HasKey()` pushed — kept only so the transition can be logged once instead of
/// every frame. It is the gate the entire keyring UI hangs off, and "the keyring never
/// appeared" is otherwise indistinguishable from "the button is mis-anchored".
had_key: bool,
/// The gate's counter memories (1439) — the four stores whose lazy `&mut` resolves poison
/// `is_changed` for this feed, watched by their explicit counters instead.
items_objects: gate::Watch,
items_templates: gate::Watch,
cooldown_epoch: gate::Watch,
names_generation: gate::Watch,
/// `PetitionState::records_epoch` — one step per landed or patched petition record, which is
/// what a charter slot's tooltip lines are built from. Lazy, so its arrival moves nothing else
/// here (the same reason `names_generation` exists one line up).
petition_records: gate::Watch,
/// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push.
enchant_deadlines: gate::Watch,
}
/// A spell's tooltip text: the $-substituted description (the real "Use: Restores 392 to 653
/// health over 21 sec" shape). Shared by the item trigger lines and the set-bonus lines.
///
/// **An empty description yields `None`, and the caller drops the whole line** — byte-verified at
/// the item builder `0x52d8a0`: it runs the $-expander into a buffer (`0x52da24` → `0x5075f0`),
/// tests the FIRST BYTE of the result (`0x52da29 mov al,[ebp-0x4f0]` / `0x52da2f test al,al`) and
/// on zero **jumps clean past the entire trigger block** (`0x52da31 je 0x52dd3d`) — past the
/// ONUSE/ONEQUIP/ONPROC prefix select and the `"%s %s"` join alike. No prefix, no line.
///
/// This used to fall back to the spell's bare NAME, which is where "Use: Opening" on a dungeon key
/// came from (director-reported): `Opening` (3365/3366/6247/6477) carries no description in
/// Spell.dbc, so the real client prints nothing and we printed the name. The fallback was an
/// invention, not a transcription — no such branch exists in the builder.
fn spell_desc_text(
spells: Option<&crate::ui_action::Spells>,
id: u32,
home_area: Option<&str>,
) -> Option<String> {
let sp = spells?;
let d = sp.catalog.get(id)?;
match d.description.as_deref().filter(|t| !t.is_empty()) {
// The $-expander can still yield an empty string from a non-empty template; the builder's
// test is on the EXPANDED text, so ours is too.
Some(desc) => {
let ctx = benilla_formats::TokenContext {
durations: &sp.durations,
radii: &sp.radii,
lookup: &|i| sp.catalog.get(i),
home_area,
};
Some(benilla_formats::substitute(desc, d, &ctx)).filter(|t| !t.is_empty())
}
None => None,
}
}
/// The tooltip's "N Charge(s)" count — 0 = no line. The real builder's charge gate
/// (byte-VERIFIED, tooltip builder `0x52d8a0`): per spell slot it normalizes a template charge
/// value of 0 to the `-1` sentinel (`0x52da01-0x52da0d`), skips the ITEM_SPELL_CHARGES line
/// entirely when the resolved value is `-1` (`0x52db51-54`), and otherwise prints `abs(value)`
/// (the cdq/xor/sub at `0x52db56-5b`). So a consumable's `-1` — "the item IS the last charge":
/// food, water, potions — shows NO line, while a real charge pool (a wand-style `-5`) shows
/// "5 Charges". We emit one line for the first surviving slot — the builder prints per slot,
/// but no item in the data carries two charge pools (VERIFIED against the live vmangos
/// `item_template`: 111 rows with a pool, none with a second).
fn charges_count(spells: &[benilla_protocol::messages::ItemSpellEntry]) -> i32 {
spells
.iter()
.find(|s| s.spell_id != 0 && s.charges != 0 && s.charges != -1)
.map(|s| s.charges.abs())
.unwrap_or(0)
}
/// A reputation rank (0..=7) → its enUS standing label (GlobalStrings
/// `FACTION_STANDING_LABEL1..8`) — the "Requires <Faction> - <Standing>" tail.
fn standing_label(rank: u32) -> &'static str {
[
"Hated",
"Hostile",
"Unfriendly",
"Neutral",
"Friendly",
"Honored",
"Revered",
"Exalted",
][rank.min(7) as usize]
}
/// Build an item template's tooltip view (decision 0274 P1): the full wire fields plus the
/// display strings only the app can resolve — trigger-spell TEXT off the spell catalog (the
/// spell's $-substituted description when it has one, per the verified law's item `$`-expander
/// `0x506f70`; its bare name otherwise), the skill requirement's name off `SkillLine.dbc`, the
/// reputation requirement off `Faction.dbc` names (the red check is the engine's, against the
/// player's rank map).
#[allow(clippy::too_many_arguments)] // one app-resolved catalog per argument, by design
fn template_view(
t: &ItemInfo,
spells: Option<&crate::ui_action::Spells>,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
home_area: Option<&str>,
factions: Option<&benilla_formats::FactionCatalog>,
sub_classes: Option<&benilla_formats::ItemSubClassCatalog>,
classes: Option<&benilla_formats::ItemClassCatalog>,
icons: Option<&ItemDisplays>,
) -> benilla_ui::script::ItemTemplateView {
let spell_name = |id: u32| -> Option<String> {
spells
.and_then(|s| s.catalog.get(id))
.map(|sd| sd.name.clone())
};
let spell_text = |id: u32| spell_desc_text(spells, id, home_area);
benilla_ui::script::ItemTemplateView {
name: t.name.clone(),
quality: t.quality,
class: t.class,
subclass: t.subclass,
inventory_type: t.inventory_type,
proficiency_alt: sub_classes.and_then(|c| c.proficiency_alt(t.class, t.subclass)),
hide_subclass: sub_classes.is_some_and(|c| c.hides_name(t.class, t.subclass)),
// `GetItemInfo`'s type pair. The subclass spelling is `name()` — VerboseName first —
// because that is the binding's own two-step (`0x48e311`), and it is deliberately NOT the
// `display_name()` the tooltip's type cell prints: a one-handed sword is "One-Handed
// Swords" to an addon and "Sword" on the tooltip. Both spellings are in the same row.
item_type: classes.and_then(|c| c.name(t.class)).map(str::to_string),
item_sub_type: sub_classes
.and_then(|c| c.name(t.class, t.subclass))
.map(str::to_string),
flags: t.flags,
bonding: t.bonding,
max_count: t.max_count,
// The stack size — a DIFFERENT wire field from `max_count` (the account-wide unique cap).
// Linen Cloth is `max_count 0, stackable 20`; `GetItemInfo`'s `itemStackCount` is this one.
stackable: t.stackable,
// The same `ItemDisplayInfo.dbc` icon the bag slots resolve, as a ready
// `Interface\Icons\…` path — `GetItemInfo`'s `itemTexture`.
icon: icons
.and_then(|i| i.catalog.get(t.display_info_id))
.and_then(|d| d.icon.clone()),
start_quest: t.start_quest,
container_slots: t.container_slots,
stats: t.stats.clone(),
damages: t.damages.iter().map(|d| (d.min, d.max, d.school)).collect(),
delay_ms: t.delay_ms,
armor: t.armor,
block: t.block,
resistances: t.resistances,
max_durability: t.max_durability,
required_level: t.required_level,
allowable_class: t.allowable_class,
allowable_race: t.allowable_race,
required_skill: t.required_skill,
required_skill_rank: t.required_skill_rank,
required_skill_name: (t.required_skill != 0)
.then(|| {
skill_lines
.and_then(|sl| sl.line(t.required_skill))
.map(|l| l.name.clone())
})
.flatten(),
required_spell: t.required_spell,
required_spell_name: (t.required_spell != 0)
.then(|| spell_name(t.required_spell))
.flatten(),
required_honor_rank: t.required_honor_rank,
required_city_rank: t.required_city_rank,
required_rep_line: (t.required_rep_faction != 0)
.then(|| {
factions
.and_then(|c| c.faction_name(t.required_rep_faction))
.map(|f| format!("Requires {f} - {}", standing_label(t.required_rep_rank)))
})
.flatten(),
required_rep_faction: t.required_rep_faction,
required_rep_rank: t.required_rep_rank,
lock_id: t.lock_id,
spell_triggers: t
.spells
.iter()
.filter(|s| s.spell_id != 0)
.filter_map(|s| spell_text(s.spell_id).map(|n| (s.trigger, s.spell_id, n)))
.collect(),
charges: charges_count(&t.spells),
description: t.description.clone(),
page_text: t.page_text,
sell_price: t.sell_price,
item_set: t.item_set,
random_property: t.random_property,
}
}
/// The §22 SET-block feed: answer the engine's ask-once set ids from ItemSet.dbc, joining
/// member NAMES from the template cache (each miss fires its own `CMSG_ITEM_QUERY` through
/// [`Items::template`], like the real client querying set members) and bonus TEXT from the
/// spell catalog's `$`-engine. A set with members still in flight stays pending and re-pushes
/// as answers land; it leaves the pending map once every member resolved. (`TokenContext.home_
/// area` is `None` here: set bonuses are equip auras — no `$z`; the passthrough would surface
/// one if the data ever carried it.)
pub(super) fn feed_item_sets(
script: Option<NonSendMut<UiScript>>,
sets: Option<Res<super::ItemSets>>,
mut items: ResMut<Items>,
commands: Res<NetCommands>,
spells: Option<Res<crate::ui_action::Spells>>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
mut pending: Local<
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
>,
) {
let Some(mut script) = script else {
return;
};
let pending = pending.get(&script);
for id in script.take_item_set_asks() {
pending.entry(id).or_default();
}
if pending.is_empty() {
return;
}
let Some(sets) = sets.as_deref() else {
return; // catalog absent (no client data): the asks stay parked, the block stays off
};
let spell_res = spells.as_deref();
let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog);
let mut done: Vec<u32> = Vec::new();
let mut push: Vec<(u32, benilla_ui::script::ItemSetView)> = Vec::new();
for (&set_id, last) in pending.iter_mut() {
let Some(row) = sets.0.set(set_id) else {
done.push(set_id); // no such row — drop the ask for good
continue;
};
let members: Vec<(u32, Option<String>)> = row
.items
.iter()
.map(|&id| (id, items.template(id, 0, &commands).map(|t| t.name.clone())))
.collect();
let view = benilla_ui::script::ItemSetView {
name: row.name.clone(),
bonuses: row
.bonuses
.iter()
.filter_map(|&(n, spell)| {
spell_desc_text(spell_res, spell, None).map(|text| (n, text))
})
.collect(),
required_skill: row.required_skill,
required_skill_rank: row.required_skill_rank,
required_skill_name: (row.required_skill != 0)
.then(|| {
skill_catalog
.and_then(|sl| sl.line(row.required_skill))
.map(|l| l.name.clone())
})
.flatten(),
members,
};
if view.members.iter().all(|(_, n)| n.is_some()) {
done.push(set_id);
}
if *last != view {
*last = view.clone();
push.push((set_id, view));
}
}
for (id, view) in push {
script.set_item_set(id, view);
}
for id in done {
pending.remove(&id);
}
}
/// The shared item-tooltip feed, both halves. **Push**: every template that lands in the app
/// cache goes to the UI store unprompted (`Items::take_fresh`) — so by the time an item's name is
/// on screen its tooltip stats are already there, and the first hover never misses (the real
/// client reads one item cache synchronously; a store the UI only fills on a read miss re-created
/// the "hover twice" flake this replaces). **Ask**: a renderer read of an id the app never
/// resolved still records a miss, which triggers the `CMSG_ITEM_QUERY` here and lands via the
/// same push when the answer arrives.
/// Push the **whole** random-suffix table into the engine, once per VM (decision 1547).
///
/// Not an ask-once feed like the templates beside it: the roll table is a static DBC the app holds
/// from load, and its consumers are click-driven — a chat-link tooltip has no hover re-enter loop
/// to repaint on a late answer. One push per VM (a `/reload` mints a new one), and it waits for
/// both catalogs, so a session that starts before the DBC load simply pushes on the next frame.
pub(super) fn feed_random_properties(
script: Option<NonSendMut<UiScript>>,
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
mut pushed: Local<crate::ui_script::VmMemo<bool>>,
) {
let Some(mut script) = script else {
return;
};
if *pushed.get(&script) {
return;
}
// The enchant catalog is what turns the roll's five ids into lines; without it every row would
// push empty and the push would never be retried. Both, or neither.
let (Some(props), Some(enchants)) = (props, enchants) else {
return;
};
let rows = crate::items::random_property_views(&props, Some(&enchants));
let with_lines = rows.values().filter(|v| !v.enchants.is_empty()).count();
info!(
"random-property table: {} rows pushed, {with_lines} with enchant lines",
rows.len()
);
script.set_random_properties(rows);
*pushed.get(&script) = true;
}
#[allow(clippy::too_many_arguments)]
pub(super) fn feed_item_stats(
script: Option<NonSendMut<UiScript>>,
mut items: ResMut<Items>,
commands: Res<NetCommands>,
spells: Option<Res<crate::ui_action::Spells>>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
// The $z token's inputs: the bind-point area id (SMSG_BINDPOINTUPDATE) named through the
// AreaTable catalog the quest-log/zone-text already carry.
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
factions: Option<Res<crate::target::Factions>>,
sub_classes: Option<Res<super::ItemSubClasses>>,
// `GetItemInfo`'s `itemType` and `itemTexture`: the class-name table, and the same
// ItemDisplayInfo icons the bag slots already resolve through.
classes: Option<Res<super::ItemClasses>>,
icons: Option<Res<ItemDisplays>>,
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
) {
let Some(mut script) = script else {
return;
};
let pending = pending.get(&script);
let last_home = last_home.get(&script);
// **`GetBindLocation()`'s push, and it happens BEFORE the early return below.**
//
// The name resolution lives here because this system already owns it for the hearthstone's
// `$z` token, and a second AreaTable lookup elsewhere is the two-parallel-paths drift that has
// cost this codebase real bugs — the binding and the token must never disagree about where the
// player is bound. But the item feed idles whenever nothing is pending, and the bind point
// arrives long after world entry (`SMSG_BINDPOINTUPDATE` at login and on every re-bind), so the
// push cannot sit behind that gate.
let home_area: Option<String> = home_bind
.as_deref()
.and_then(|b| b.0)
.and_then(|id| area_names.as_deref()?.0.resolve(id as i32))
.map(str::to_string);
script.set_bind_location(home_area.as_deref().unwrap_or_default());
pending.extend(items.take_fresh());
pending.extend(script.take_item_stat_asks());
if pending.is_empty() {
return;
}
let spell_res = spells.as_deref();
let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog);
// A bind-point change re-substitutes every held view: templates pushed before the login's
// SMSG_BINDPOINTUPDATE landed carry a raw $z otherwise (the hearthstone's login race).
if *last_home != home_area {
*last_home = home_area.clone();
pending.extend(items.cached_template_ids());
}
let ready: Vec<u32> = pending
.iter()
.copied()
.filter(|&id| items.template(id, 0, &commands).is_some())
.collect();
for id in ready {
pending.remove(&id);
let Some(t) = items.template(id, 0, &commands) else {
continue;
};
let view = template_view(
&t.clone(),
spell_res,
skill_catalog,
home_area.as_deref(),
factions.as_deref().map(|f| f.catalog()),
sub_classes.as_deref().map(|s| &s.0),
classes.as_deref().map(|c| &c.0),
icons.as_deref(),
);
script.set_item_template(id, view);
}
}
/// The red-line law's player state (decision 0274 P1): level + class/race ids (the allowable-mask
/// bits) + the full skill-rank map, read off the self player's descriptor, plus the equip
/// proficiencies (`SMSG_SET_PROFICIENCY`) and the faction → reputation-rank map (DBC base for our
/// race/class + the `SMSG_INITIALIZE_FACTIONS` standing, ranked) — pushed on change.
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
pub(super) fn feed_player_req(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
proficiencies: Res<crate::net::Proficiencies>,
reputations: Res<crate::net::Reputations>,
factions: Option<Res<crate::target::Factions>>,
actions: Res<crate::ui_action::PlayerActions>,
spells: Option<Res<crate::ui_action::Spells>>,
mut last: Local<crate::ui_script::VmMemo<Option<benilla_ui::script::PlayerReqState>>>,
) {
let Some(mut script) = script else {
return;
};
let (last, vm_reset) = last.get_reset(&script);
// The gate (1439): the state below is a pure function of these six — the self descriptor
// (skills/level/race/class/honor; its absence returns pushless), the proficiency and
// reputation mirrors, the faction catalog, and the known-spell set with its catalog.
let self_changed = !changed_self.is_empty();
let prof_changed = proficiencies.is_changed();
let reps_changed = reputations.is_changed();
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
let actions_changed = actions.is_changed();
let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed());
gate::trace(
"feed_player_req",
&[
("vm_reset", vm_reset),
("self", self_changed),
("proficiencies", prof_changed),
("reputations", reps_changed),
("factions", factions_changed),
("actions", actions_changed),
("spells", spells_changed),
],
);
let gate = gate::Gate::new(
vm_reset
|| self_changed
|| prof_changed
|| reps_changed
|| factions_changed
|| actions_changed
|| spells_changed,
);
if gate.skip() {
return;
}
let Some(store) = self_q.iter().next() else {
return;
};
let mut skills = std::collections::HashMap::new();
for slot in 0..benilla_protocol::messages::PLAYER_SKILL_SLOTS {
let Some(s) = store.0.player_skill(slot) else {
continue;
};
if s.skill_id != 0 {
// The gate laws (`0x5eaae0`, `0x5ea930`) read value + PERM bonus (the talent half;
// the temp half never counts toward requirements). The client zero-extends the bonus
// word before a signed compare — a negative perm bonus can't happen in live data, so
// the sane saturating add mirrors every reachable case.
skills.insert(
u32::from(s.skill_id),
u32::from(s.value).saturating_add_signed(i32::from(s.perm_bonus)),
);
}
}
let race = store.0.unit_race().unwrap_or(0);
let class = store.0.unit_class().unwrap_or(0);
let mut rep_ranks = std::collections::HashMap::new();
if let Some(cat) = factions.as_deref().map(|f| f.catalog()) {
for (id, info) in cat.reputation_factions() {
let standing = usize::try_from(info.rep_index)
.ok()
.and_then(|i| reputations.0.get(i))
.map(|&(_, s)| s)
.unwrap_or(0);
rep_ranks.insert(
id,
benilla_formats::reputation_rank(info.base_for(race, class) + standing),
);
}
}
// The client's `0xc4d770`: set when a spell whose Effect[0] is 40 (SPELL_EFFECT_DUAL_WIELD)
// is learned, cleared on its unlearn — mirrored as a spellbook scan.
let can_dual_wield = spells.as_deref().is_some_and(|s| {
actions
.spells
.iter()
.any(|&id| s.catalog.get(id).is_some_and(|sd| sd.effects[0] == 40))
});
let state = benilla_ui::script::PlayerReqState {
level: store.0.unit_level().unwrap_or(0),
class_id: u32::from(class),
race_id: u32::from(race),
skills,
proficiency: proficiencies.0.clone(),
rep_ranks,
can_dual_wield,
honor_rank: store.0.player_honor_rank().unwrap_or(0),
};
if last.as_ref() != Some(&state) {
gate.audit("feed_player_req", "the player-requirement state");
*last = Some(state.clone());
script.set_player_req_state(state);
}
}
/// One bag slot from its item guid: the instance (store) + template (cache, ask-once) + icon
/// (DBC) + the use-spell's running cooldown (`GetContainerItemCooldown`'s data — the same store
/// read the action feed's ITEM arm does). `None` = the slot is empty (guid 0/unsent) — an
/// *unresolved* occupied slot is `Some` with empty fields instead, so the bag shows the item
/// exists before its query answers.
#[allow(clippy::too_many_arguments)] // the slot resolve's full read set (stores + both clocks)
fn resolve_slot(
guid: u64,
items: &mut Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
cooldowns: &crate::cooldowns::Cooldowns,
spells: Option<&benilla_formats::SpellCatalog>,
names: &mut crate::names::NameCache,
// The petition record cache, for a charter slot's tooltip lines — a LAZY fill, so it is taken
// mutably for the same reason `names` is: the read is what issues the query.
petitions: &mut crate::ui_petition::PetitionState,
now: std::time::Instant,
ui_now: f64,
) -> Option<ContainerSlot> {
if guid == 0 {
return None;
}
// The temporary-enchant countdowns, read off the deadline store BEFORE the object borrow below
// (both live on `Items`): one `Option<ms>` per enchant slot.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) =
match items.object(guid) {
Some(fields) => (
fields.object_entry().unwrap_or(0),
fields.item_stack_count().unwrap_or(1),
// The live instance pair — the wire updates ITEM_FIELD_DURABILITY on damage/repair
// (death 10%, spirit healer 25%); max 0 = indestructible ⇒ no line.
fields
.item_durability()
.zip(fields.item_max_durability())
.filter(|&(_, max)| max > 0),
// The instance carries letter text (a mail permanent copy) — the same gate the
// right-click reader fork uses (`ui_items::drain`); the hover magnifier keys off it.
// Template-`PageText` books join when the books read path lands (decision 0572).
fields.item_text_id().is_some_and(|id| id != 0),
// `ITEM_FIELD_CREATOR` → the ask-once name cache: the tooltip's "Written by %s" /
// "<Made by %s>" line. `None` while the query is in flight — the changed re-push
// repaints the hover when the answer lands (the ref's cache-callback shape).
fields
.item_creator()
.filter(|&g| g != 0)
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
// `ITEM_FIELD_FLAGS` — the tooltip's UNLOCKED (0x4) / WRAPPED (0x8) sub-gates.
fields.item_flags().unwrap_or(0),
// `0x5da2c0` — soulbound, or carrying a binding enchant: the §6 Soulbound
// override (B310). Read off the raw descriptor, not off the enchant LINES below.
crate::items::already_bound(fields, rolls.enchants),
// `ITEM_FIELD_RANDOM_PROPERTIES_ID` — the roll behind the NAME's "of the Bear"
// (decision 1547). Only the name: the roll's own enchants are already in the
// instance's slots 2..6 below, written there by the server.
fields.item_random_properties_id(),
// The instance's own 7 enchant slots — the tooltip's enchant lines (0915/0920). An
// item we hold streams as an OBJECT, so all seven are here, with their charges and
// their `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdowns; the wire's 2-slot broadcast is
// what everyone ELSE's items are limited to.
crate::items::enchant_lines(
(0..7).map(|s| {
(
s,
fields.item_enchant(s).unwrap_or(0),
fields.item_enchant_charges(s),
enchant_ms[usize::from(s)],
)
}),
rolls.enchants,
),
),
// The player descriptor references a guid whose create hasn't landed (yet) —
// occupied, unresolved.
None => return Some(ContainerSlot::default()),
};
if entry == 0 {
return Some(ContainerSlot::default());
}
let template: Option<ItemInfo> = items.template(entry, guid, commands).cloned();
let Some(t) = template else {
// Asked (or a cached negative); show the slot occupied while the answer is in flight.
return Some(ContainerSlot {
durability: None,
item_id: entry,
count,
readable,
creator,
flags,
already_bound,
enchants: enchant_lines,
..Default::default()
});
};
Some(ContainerSlot {
// **Line 3 of the tooltip law** — a charter's guild name and master. Gated on the template's
// signable flag, then keyed by the petition id the server stuffs into the item's
// `ITEM_FIELD_ENCHANTMENT` slot 0 (`PetitionsHandler.cpp:126`) — which is the same field the
// client reads it from (`0x5ef337`), and the same one the tooltip's ENCHANT lines are
// forced to skip for exactly this reason (`0x52c9e0 test ah,0x20`). So slot 0 on a charter
// is a petition id by contract on both ends, and this is its one consumer here.
//
// The lookup is lazy: hovering an unopened charter is what sends the query, so the first
// hover shows the name and the green line alone. The creator line right below has the same
// rule.
petition: (t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0)
.then(|| {
let id = items
.object(guid)
.and_then(|f| f.item_enchant(0))
.unwrap_or(0);
petitions.tooltip_view(id as u32, guid, names, commands)
})
.flatten(),
texture: icons
.and_then(|i| i.catalog.get(t.display_info_id))
.and_then(|d| d.icon.clone()),
count,
durability,
quality: Some(t.quality),
item_id: entry,
// The link carries the roll — both in its `randomPropertyId` field and in the bracketed
// NAME, which the reference builds out of the suffix-joining formatter `0x5d8b00`. The
// slot's tooltip plate reads its name back off this string.
link: Some(crate::ui_items::item_link_full(
entry,
0,
roll,
0,
&rolls.name(&t.name, roll),
t.quality,
)),
locked: false,
readable,
creator,
flags,
already_bound,
enchants: enchant_lines,
equip_slots: find_equip_slot(t.inventory_type),
bar_placeable: t.placeable_on_action_bar(),
cooldown: t.use_spell.and_then(|u| {
let sd = spells.and_then(|s| s.get(u.spell_id));
cooldowns
.info(u.spell_id, entry, sd, now)
.ui_triple(now, ui_now)
}),
})
}
/// Reason 16's `%s`: the destination bag's **`BagFamily` name** — "Arrows", "Soul Shards",
/// "Herbs" — for *"Only %s can be placed in that."* `None` = no bag to name, so the caller keeps
/// the generic `ERR_WRONG_BAG_TYPE` line.
///
/// `bag_slot` is the wire's ABSOLUTE player slot (see
/// `benilla_protocol::messages::items::read_inventory_change_failure`), and the reference's helper
/// `0x5ede00` bails on exactly two shapes we mirror: `slot == 0xFF` (`INVENTORY_SLOT_BAG_0`, the
/// player's own array — a backpack/equipment refusal names no container), and a slot past the
/// player's slot array. Everything else it indexes straight into that array and resolves as an
/// item.
///
/// Despite the errorId's `_SUBCLASS` name this reads the bag's `BagFamily`, **not** its
/// ItemSubClass — which is what makes a quiver say "Only Arrows can be placed in that." rather
/// than naming the quiver's own type (`benilla_formats::itembagfamily`, and the DBC read there).
///
/// The bank-bag leg (63..=68) is ours by symmetry rather than byte-pinned: the reference bounds
/// this on `[player+0x1d38]`, whose value wow-re did not resolve, so whether a bank bag reaches
/// the substitution or falls to the generic line is unverified. Both outcomes are ordinary
/// sentences; resolving it is the strictly more useful one, and it is flagged here rather than
/// silently assumed.
fn bag_family_name(
player: Option<&ObjectStore>,
bag_slot: u8,
items: &mut Items,
families: Option<&benilla_formats::ItemBagFamilyCatalog>,
commands: &NetCommands,
) -> Option<String> {
let store = player?;
let families = families?;
let guid = match bag_slot {
// The equipped bag slots.
s if (BAG_SLOT_FIRST..BAG_SLOT_FIRST + BAGS).contains(&s) => store.0.player_inv_slot(s),
// The purchasable bank bag slots.
s if (BANK_BAG_SLOT_FIRST..BANK_BAG_SLOT_FIRST + BANK_BAGS).contains(&s) => {
store.0.player_bank_bag_slot(s - BANK_BAG_SLOT_FIRST)
}
// 255 = the player's own array, and anything past the bag slots: no container to name.
_ => None,
}
.filter(|&g| g != 0)?;
let entry = items.object(guid)?.object_entry().filter(|&e| e != 0)?;
let family = items.template(entry, guid, commands)?.bag_family;
families.name(family).map(str::to_string)
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // the param list IS the input set
pub(crate) fn feed_containers(
script: Option<NonSendMut<UiScript>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// The two item-DBC catalogs, as one param (the 16-SystemParam ceiling): `SpellItemEnchantment`'s
// name column — the tooltip's enchant lines (decision 0915) — and `ItemRandomProperties`, the
// roll behind a slot's "of the Monkey" name (decision 1547).
catalogs: (
Option<Res<crate::items::Enchants>>,
Option<Res<crate::items::RandomProperties>>,
),
// The two self-descriptor legs in one param (the 16-SystemParam ceiling): the store the
// slot arrays read, and its change tick — the gate's cheapest input (1439).
self_q: (
Query<&ObjectStore, With<SelfPlayer>>,
Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
),
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
spells: Option<Res<crate::ui_action::Spells>>,
mut equip_errors: ResMut<EquipErrors>,
// Reason 16's `%s` source (decision 0916); absent = every 16 keeps the generic line.
bag_families: Option<Res<crate::ui_items::ItemBagFamilies>>,
mut pending: ResMut<PendingItemOps>,
mut lock_cleared: ResMut<LockClearedByFailure>,
mut names: ResMut<crate::names::NameCache>,
// Paired into one param (the 16-SystemParam ceiling this signature already sits at): the UI
// clock, and the petition record cache a charter slot's tooltip lines read — `ResMut` because
// that cache is LAZY and the hover is what issues its query.
clock_and_petitions: (
Res<crate::ui_script::UiClock>,
ResMut<crate::ui_petition::PetitionState>,
),
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
) {
let Some(mut script) = script else {
return;
};
let (clock, mut petitions) = clock_and_petitions;
let (memory, vm_reset) = memory.get_reset(&script);
// The gate (1439): the snapshot below is a function of the self descriptor's slot arrays,
// the item stores (both epochs — `is_changed` on `Items`/`NameCache`/`Cooldowns` is
// poisoned by per-frame lazy resolves, so the counters carry the truth), the four
// catalogs, and the drained side-channels (each held open while non-empty; their
// *arrival* frames are covered because a drain writer marks them non-empty). `UiClock`
// is deliberately NOT an input: the pushed cooldown triple carries the ABSOLUTE start
// (frame-stable, the memory struct's own doc), and natural expiry moves the store's
// `feed_epoch` through the prune.
let objects_moved = memory.items_objects.moved(items.object_epoch());
let templates_moved = memory.items_templates.moved(items.template_epoch());
let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch());
let names_moved = memory.names_generation.moved(names.generation());
let petitions_moved = memory.petition_records.moved(petitions.records_epoch());
// The DISPLAY epoch (see `feed_char`'s twin comment): the snapshot's countdowns are
// second-floored, so one watch step per displayable change replaces the per-frame hold-open
// that rebuilt every bag snapshot for the whole life of a ticking enchant.
let deadlines_moved = memory
.enchant_deadlines
.moved(items.enchant_display_epoch());
// Bound as `let`s (not a bare OR-chain) so the gate trace below can name each input.
let sweep = cooldowns.sweep_pending(clock.anchor);
let self_changed = !self_q.1.is_empty();
// `is_added`, NOT `is_changed`: the feeds read only the load-once icon CATALOG off this
// resource (its struct doc says so verbatim); the world's held-model cache half is
// get-or-insert every frame, so `is_changed` reads true forever — 1439's gate-trace found
// the containers gate held open by exactly this.
let icons_changed = icons.as_ref().is_some_and(|r| r.is_added());
// Both DBC catalogs load once, at startup — one input covers the pair.
let enchants_changed = catalogs.0.as_ref().is_some_and(|r| r.is_changed())
|| catalogs.1.as_ref().is_some_and(|r| r.is_changed());
let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed());
let families_changed = bag_families.as_ref().is_some_and(|r| r.is_changed());
let errors_held = !equip_errors.0.is_empty();
let locks_held = !lock_cleared.0.is_empty() || !pending.is_empty();
gate::trace(
"feed_containers",
&[
("vm_reset", vm_reset),
("objects", objects_moved),
("templates", templates_moved),
("cooldowns", cooldowns_moved),
("sweep", sweep),
("names", names_moved),
("petition-records", petitions_moved),
("deadlines", deadlines_moved),
("self", self_changed),
("icons", icons_changed),
("enchants", enchants_changed),
("spells", spells_changed),
("families", families_changed),
("errors", errors_held),
("locks", locks_held),
],
);
let gate = gate::Gate::new(
vm_reset
|| objects_moved
|| templates_moved
|| cooldowns_moved
// The frame a timer crosses zero, BEFORE the per-frame prune has moved the epoch
// (`sweep_pending`'s own doc) — the slot triple flips to None right then.
|| sweep
|| names_moved
|| petitions_moved
|| deadlines_moved
|| self_changed
|| icons_changed
|| enchants_changed
|| spells_changed
|| families_changed
|| errors_held
|| locks_held,
);
if gate.skip() {
return;
}
// `WOW_FEED_COST=1` — the counter that PRICED gating this feed (the 2026-08-15 ledger's #8,
// built when the snapshot+diff below still ran every frame; the gate above is 1439's
// answer). Kept as the gate's wiring instrument: it accumulates only when the body runs, so
// a parked gated run prints (almost) nothing where the ungated binary printed once a second.
let cost_t0 = std::env::var_os("WOW_FEED_COST")
.is_some()
.then(std::time::Instant::now);
// The frame's atomic clock pair (`crate::ui_script::UiClock`): the slot resolves read the
// cooldown store and convert triples through ONE coherent instant, so a running cooldown's
// pushed start is frame-stable (the resource's own doc).
let (now, ui_now) = (clock.anchor, clock.ui_now);
let spell_catalog = spells.as_deref().map(|s| &s.catalog);
let rolls = crate::items::RollCatalogs {
enchants: catalogs.0.as_deref(),
props: catalogs.1.as_deref(),
};
// Inventory refusals surface as the client's red error line (the cast path's exact shape):
// the wire code keys into the VM's own GlobalStrings ([`equip_error_key`], total), reason 1
// filling its `%d` with the packet's required level.
//
// **An unresolvable or empty key prints NOTHING** — this is the reference's own behaviour,
// not a fallback: `CGGameUI::DisplayError` is called unconditionally and the sink's
// `cmp byte [ecx],0` guard (`0x4945b4`) drops an empty string before it renders or sounds.
// It is what silences reason 59, the lock-clear sentinel that rides alongside a real refusal
// and used to print a second, hex-debug line over it (B198) — and it is the same law
// `ui_action::cast_fail` already runs. No hex debug line on the player's screen: a code we
// failed to map can't reach here (the table is total), and a key we typo'd is caught by
// `equip_error`'s resolution test against the real `GlobalStrings.lua`, not at runtime.
let player = self_q.0.iter().next();
for e in equip_errors.0.drain(..) {
// Reason 16's substitution — the ONE reason whose text is chosen by the app rather than
// by the table, because the choice needs the named bag (decision 0916). The reference's
// helper `0x5ede00(player, bagSlot)` resolves the bag and calls
// `DisplayError(ERR_WRONG_BAG_TYPE_SUBCLASS, familyName)` *itself*, returning 1 so the
// caller skips the generic line; a bag that doesn't resolve leaves the generic
// `ERR_WRONG_BAG_TYPE` standing. Same fork, same fallback.
let subclass_fill = (e.reason == 16)
.then(|| {
bag_family_name(
player,
e.bag_slot,
&mut items,
bag_families.as_deref().map(|c| &c.0),
&commands,
)
})
.flatten();
let key = match subclass_fill {
Some(_) => "ERR_WRONG_BAG_TYPE_SUBCLASS",
None => equip_error_key(e.reason),
};
let text = script
.lua()
.globals()
.get::<String>(key)
.unwrap_or_default();
if text.is_empty() {
continue;
}
// The two argument-taking reasons, each filling its own specifier. Neither code ever
// carries the other's fill, so the order is bookkeeping, not precedence.
let text = match e.required_level {
Some(d) => text.replace("%d", &d.to_string()),
None => text,
};
let text = match subclass_fill {
Some(family) => text.replace("%s", &family),
None => text,
};
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
// The pending-lock resolving clear (decision 0216 §4 / 0218 §3 "the field-update watcher"):
// walk the SAME guids the slot loop below is about to read and release any op whose slots have
// moved on. Folded in with the failure-driven clears `net/apply/loot.rs::inventory_failure`
// queued (it has no `UiScript` to fire through) — both fire `ITEM_LOCK_CHANGED` here, the bag
// windows' own repaint trigger (0218: the popup's No/ESC clear paths the bag never clicked
// through, so only the event reaches it). Fired AFTER the slot loop below pushes the corrected
// `.locked` state, so the repaint they trigger sees the unlocked slot, not stale data.
let mut transitioned: Vec<(i64, u32)> = if player.is_some() {
pending.resolve(|bag, slot1| slot_guid_count(player, bag, slot1, &items))
} else {
Vec::new()
};
transitioned.append(&mut lock_cleared.0);
let mut fresh: HashMap<i64, ContainerState> = HashMap::new();
if let Some(store) = player {
// Bag 0: the backpack — its slots live directly in the player descriptor.
let mut slots = HashMap::new();
for i in 0..PACK_SLOTS {
let guid = store.0.player_pack_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&mut items,
icons.as_deref(),
rolls,
&commands,
&cooldowns,
spell_catalog,
&mut names,
&mut petitions,
now,
ui_now,
) {
slot.locked = pending.contains(0, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
}
}
fresh.insert(
0,
ContainerState {
name: Some("Backpack".into()),
num_slots: u32::from(PACK_SLOTS),
slots,
},
);
// Bags 1..4: each INV bag slot holds a container object with its own slot array.
for bag in 1..=BAGS {
let bag_guid = store
.0
.player_inv_slot(BAG_SLOT_FIRST + bag - 1)
.unwrap_or(0);
if bag_guid == 0 {
continue; // no bag equipped → absent → GetContainerNumSlots = 0
}
let (entry, num_slots, slot_guids) = match items.object(bag_guid) {
Some(f) => {
let n = f.container_num_slots().unwrap_or(0);
let guids: Vec<u64> = (0..n.min(36) as u8)
.map(|j| f.container_slot(j).unwrap_or(0))
.collect();
(f.object_entry().unwrap_or(0), n, guids)
}
None => (0, 0, Vec::new()),
};
let name = (entry != 0)
.then(|| {
items
.template(entry, bag_guid, &commands)
.map(|t| t.name.clone())
})
.flatten();
let mut slots = HashMap::new();
for (j, &guid) in slot_guids.iter().enumerate() {
if let Some(mut slot) = resolve_slot(
guid,
&mut items,
icons.as_deref(),
rolls,
&commands,
&cooldowns,
spell_catalog,
&mut names,
&mut petitions,
now,
ui_now,
) {
slot.locked = pending.contains(i64::from(bag), j as u32 + 1);
slots.insert(j as u32 + 1, slot);
}
}
fresh.insert(
i64::from(bag),
ContainerState {
name,
num_slots,
slots,
},
);
}
// The bank (decision 0604): container −1 = the 24 generic vault slots straight off the
// player descriptor, containers 5..=10 = the bank bags — each a container object exactly
// like an equipped bag. Fed unconditionally like the backpack: the descriptor streams at
// login, the window (BANKFRAME_OPENED) is a UI concern, not a data one.
let mut slots = HashMap::new();
for i in 0..BANK_SLOTS {
let guid = store.0.player_bank_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&mut items,
icons.as_deref(),
rolls,
&commands,
&cooldowns,
spell_catalog,
&mut names,
&mut petitions,
now,
ui_now,
) {
slot.locked = pending.contains(BANK_CONTAINER, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
}
}
fresh.insert(
BANK_CONTAINER,
ContainerState {
name: Some("Bank".into()),
num_slots: u32::from(BANK_SLOTS),
slots,
},
);
for bank_bag in 0..BANK_BAGS {
let bag_id = BANK_BAG_ID_FIRST + i64::from(bank_bag);
let bag_guid = store.0.player_bank_bag_slot(bank_bag).unwrap_or(0);
if bag_guid == 0 {
continue; // no bag in the slot → absent → GetContainerNumSlots = 0
}
let (entry, num_slots, slot_guids) = match items.object(bag_guid) {
Some(f) => {
let n = f.container_num_slots().unwrap_or(0);
let guids: Vec<u64> = (0..n.min(36) as u8)
.map(|j| f.container_slot(j).unwrap_or(0))
.collect();
(f.object_entry().unwrap_or(0), n, guids)
}
None => (0, 0, Vec::new()),
};
let name = (entry != 0)
.then(|| {
items
.template(entry, bag_guid, &commands)
.map(|t| t.name.clone())
})
.flatten();
let mut slots = HashMap::new();
for (j, &guid) in slot_guids.iter().enumerate() {
if let Some(mut slot) = resolve_slot(
guid,
&mut items,
icons.as_deref(),
rolls,
&commands,
&cooldowns,
spell_catalog,
&mut names,
&mut petitions,
now,
ui_now,
) {
slot.locked = pending.contains(bag_id, j as u32 + 1);
slots.insert(j as u32 + 1, slot);
}
}
fresh.insert(
bag_id,
ContainerState {
name,
num_slots,
slots,
},
);
}
// The keyring (decision 0765): container −2, the player array's slots 81.., no container
// object of its own — structurally the bank's twin. Its capacity is NOT a wire field: both
// the reference (`GetKeyRingSize`) and the server (`GetMaxKeyringSize`) derive it from the
// player's level with the same ladder, so [`keyring_size`] computes it here and Lua's
// `GetKeyRingSize()` reads it back off this snapshot. Slots past that count are never fed
// (they can't hold anything — the server refuses to store there), so the window's own
// physIndex-past-size branch hides exactly the right buttons with no keyring-specific code.
let size = keyring_size(store.0.unit_level().unwrap_or(1));
let mut slots = HashMap::new();
for i in 0..size.min(u32::from(KEYRING_SLOTS)) as u8 {
let guid = store.0.player_keyring_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&mut items,
icons.as_deref(),
rolls,
&commands,
&cooldowns,
spell_catalog,
&mut names,
&mut petitions,
now,
ui_now,
) {
slot.locked = pending.contains(KEYRING_CONTAINER, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
}
}
fresh.insert(
KEYRING_CONTAINER,
ContainerState {
name: Some("Keyring".into()),
num_slots: size,
slots,
},
);
// `HasKey()` — the gate that decides whether the keyring exists in the UI at all. Pushed
// beside the containers because it is the same knowledge (item templates) read over the
// same slot arrays, and it must be fresh on exactly the frames a BAG_UPDATE fires.
let key = has_key(&store.0, &mut items, &commands);
if key != memory.had_key {
gate.audit("feed_containers", "the HasKey() flip");
debug!(
"ui_items: HasKey() -> {key} (keyring {})",
if key { "shown" } else { "hidden" }
);
memory.had_key = key;
}
script.set_has_key(key);
}
if let Some(t0) = cost_t0 {
use std::sync::atomic::{AtomicU64, Ordering};
static SUM_NS: AtomicU64 = AtomicU64::new(0);
static N: AtomicU64 = AtomicU64::new(0);
let sum = SUM_NS.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
let n = N.fetch_add(1, Ordering::Relaxed) + 1;
if n.is_multiple_of(60) {
let slots: usize = fresh.values().map(|c| c.slots.len()).sum();
eprintln!(
"[feed-cost] ms={:.3} slots={slots} containers={}",
sum as f64 / n as f64 / 1e6,
fresh.len(),
);
}
}
if apply_container_source(
&mut script,
memory,
player.is_some().then_some(fresh),
transitioned,
) {
gate.audit("feed_containers", "a bag diff or a lock event");
}
}
/// The feed's outward half: diff `source` against what was last pushed, push each changed bag into
/// the VM and fire the reference's events — `BAG_UPDATE(bagID)` (`PLAYERBANKSLOTS_CHANGED(slot)`
/// for the vault), one `BAG_UPDATE_DELAYED` per batch, then the lock transitions.
///
/// **`source: None` means the self player STORE is absent this frame — "no data source", never
/// "the player has no items."** The two absent windows are pre-arrival at login (the fresh VM's
/// containers are already empty; nothing to say) and the logout despawn frames:
/// `SMSG_LOGOUT_COMPLETE` despawns the self entity (`net/apply/session.rs::logged_out`) at least
/// one full Update before the `OnExit(InWorld)` shutdown, so this runs against a still-live VM.
/// Diffing the absence as an all-empty snapshot fired a full `BAG_UPDATE` burst whose every bag
/// read `GetContainerNumSlots() == 0` — and an addon that mirrors bags into its saved variables
/// deletes a bag's record on size 0 (Bagnon_Forever's `SaveBagData`), so the burst erased the
/// whole record moments before [`crate::ui_script::shutdown_ui_state`] wrote the file. That was
/// the director's offline-bags report: every recently-logged-out character money-only, the view
/// stale. The reference never delivers such a burst — its UI shutdown (`0x490bd0`) runs with the
/// inventory intact — so here the VM simply keeps its last-pushed state and the shutdown writes
/// the bags the player actually had. Lock-clear events still flush either way: they are
/// packet-driven and must not sit around to fire into a later VM.
pub(crate) fn apply_container_source(
script: &mut UiScript,
memory: &mut FeedMemory,
source: Option<HashMap<i64, ContainerState>>,
transitioned: Vec<(i64, u32)>,
) -> bool {
// Diff whole bags; push + fire BAG_UPDATE per transition, one BAG_UPDATE_DELAYED per batch. A
// pending-lock transition always flips a slot's `.locked` (part of `ContainerSlot`'s equality),
// so it always shows up here too — but `transitioned`'s ITEM_LOCK_CHANGED fires unconditionally
// below rather than leaning on that invariant.
let mut pushed = false;
if let Some(fresh) = source {
pushed |= diff_and_push(script, memory, fresh);
}
// The lock-transition event (decision 0218: the bag windows' own repaint trigger) — after the
// container push above, so a listener's repaint reads the corrected `.locked` state.
pushed |= !transitioned.is_empty();
for (bag, slot) in transitioned {
script.fire_event(
"ITEM_LOCK_CHANGED",
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
);
}
// Whether anything went into the VM — the caller's gate audit reads it (1439).
pushed
}
/// The present-source half of [`apply_container_source`]: push every changed bag, announce each.
fn diff_and_push(
script: &mut UiScript,
memory: &mut FeedMemory,
fresh: HashMap<i64, ContainerState>,
) -> bool {
let changed: Vec<i64> = fresh
.keys()
.chain(memory.pushed.keys())
.copied()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.filter(|b| fresh.get(b) != memory.pushed.get(b))
.collect();
if !changed.is_empty() {
// **The spent-ammo signal** (decision 1509). The reference registers a field mirror on
// `ITEM_FIELD_STACK_COUNT` for TYPEID ITEM (`ClntObjMgrSetTypeMirrorHandler 0x468070` at
// `0x5d9360`, handler `0x5d9400`); for any item the active player owns, a stack-count
// write fires **ITEM_LOCK_CHANGED first**, ahead of the `BAG_UPDATE` the same handler
// emits further down. Nothing else in the image reaches that event from a server update —
// its other four fire sites are all local lock/unlock — so it is the ONLY per-shot signal
// an addon can detect a spent arrow with, and every auto-shot timer is built on it.
//
// Computed before `memory.pushed` is replaced at the bottom of this block.
let restacked = {
let empty = HashMap::new();
let mut v: Vec<(i64, u32)> = Vec::new();
for &bag in &changed {
let now = fresh.get(&bag).map_or(&empty, |c| &c.slots);
let was = memory.pushed.get(&bag).map_or(&empty, |c| &c.slots);
for (&slot, n) in now {
// The SAME item, restacked. A slot whose entry changed is a create or a swap,
// not a field write on one item, and it does not take this path.
if was.get(&slot).is_some_and(|w| {
w.item_id != 0 && w.item_id == n.item_id && w.count != n.count
}) {
v.push((bag, slot));
}
}
}
// Map order is not an order, and an event stream has to be reproducible.
v.sort_unstable();
v
};
for &bag in &changed {
script.set_container(bag, fresh.get(&bag).cloned());
}
// Ahead of BAG_UPDATE below, which is the reference handler's own order.
for (bag, slot) in restacked {
script.fire_event(
"ITEM_LOCK_CHANGED",
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
);
}
// Name the bags, not just the count: "3 changed" can't tell you WHICH container moved, and
// the negative ids (−1 bank, −2 keyring) are exactly the ones you go looking for.
debug!(
"ui_items: fed {} changed bag(s) — {}",
changed.len(),
changed
.iter()
.map(|b| format!("{b}:{}", fresh.get(b).map_or(0, |c| c.slots.len())))
.collect::<Vec<_>>()
.join(" ")
);
for &bag in &changed {
// The vault fires the reference's own event, per changed slot with the slot id
// (`PLAYERBANKSLOTS_CHANGED(slot)` — BankFrame repaints the one button); everything
// else — backpack, equipped bags, AND bank bags (ordinary container frames in the
// reference) — fires BAG_UPDATE(bagID).
if bag == BANK_CONTAINER {
let empty = HashMap::new();
let now_slots = fresh.get(&bag).map(|c| &c.slots).unwrap_or(&empty);
let was_slots = memory.pushed.get(&bag).map(|c| &c.slots).unwrap_or(&empty);
for slot in 1..=u32::from(BANK_SLOTS) {
if now_slots.get(&slot) != was_slots.get(&slot) {
script.fire_event(
"PLAYERBANKSLOTS_CHANGED",
vec![ScriptValue::Int(i64::from(slot))],
);
}
}
} else {
script.fire_event("BAG_UPDATE", vec![ScriptValue::Int(bag)]);
}
}
memory.pushed = fresh;
script.fire_event("BAG_UPDATE_DELAYED", vec![]);
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::{apply_container_source, charges_count, FeedMemory};
use benilla_protocol::messages::ItemSpellEntry;
use benilla_ui::script::{ContainerState, UiScript};
use std::collections::HashMap;
/// The logout-wipe law (the director's stale-offline-bags report): an **absent** self player
/// store is "no data source", never "the player has no items". `SMSG_LOGOUT_COMPLETE` despawns
/// the self entity a full Update before `OnExit(InWorld)` shuts the VM down, and the feed used
/// to diff that absence as an all-empty snapshot — a `BAG_UPDATE` burst whose every bag read
/// `GetContainerNumSlots() == 0`, which made Bagnon_Forever erase its records right before the
/// saved-variables write. A *present* source that lost a bag is a real transition and must
/// still announce — the law gates on the source's existence, not on its emptiness.
#[test]
fn an_absent_self_player_is_no_source_never_an_empty_bag_burst() {
let mut s = UiScript::new().unwrap();
s.run(
"BAG_EVENTS = 0 \
local f = CreateFrame('Frame') \
f:RegisterEvent('BAG_UPDATE') \
f:SetScript('OnEvent', function() BAG_EVENTS = BAG_EVENTS + 1 end)",
)
.unwrap();
let bag0 = ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: HashMap::new(),
};
let mut memory = FeedMemory::default();
// The in-session push: source present, bag new → pushed + announced.
apply_container_source(
&mut s,
&mut memory,
Some(HashMap::from([(0, bag0)])),
Vec::new(),
);
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 16);
assert_eq!(s.eval::<i64>("return BAG_EVENTS").unwrap(), 1);
// The logout despawn frame: no store. The VM keeps its last-pushed bags and no event
// fires — an addon reading its bags out of the PLAYER_LOGOUT edge sees them intact.
apply_container_source(&mut s, &mut memory, None, Vec::new());
assert_eq!(
s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(),
16,
"an absent source must not empty the VM's containers"
);
assert_eq!(
s.eval::<i64>("return BAG_EVENTS").unwrap(),
1,
"an absent source must not fire a BAG_UPDATE burst"
);
// A PRESENT source without the bag is a genuine transition: push + announce.
apply_container_source(&mut s, &mut memory, Some(HashMap::new()), Vec::new());
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 0);
assert_eq!(s.eval::<i64>("return BAG_EVENTS").unwrap(), 2);
}
/// **The spent-ammo signal** (decision 1509, B267's second half). A stack ticking down must
/// fire `ITEM_LOCK_CHANGED` for that slot, **before** the `BAG_UPDATE` for its bag — the
/// reference's `ITEM_FIELD_STACK_COUNT` mirror handler's own order.
///
/// This is not cosmetic ordering. Quiver's auto-shot timer has no other way to learn a shot
/// fired: it starts the reload drain from this event, and without it the bar fills once and
/// sits at 100% forever (the director's report). Every vanilla shot timer works this way.
///
/// The negative half matters as much: a slot whose ENTRY changed is a swap or a create, not a
/// field write on one item, and must NOT fire it — over-firing would make the addon count
/// shots that never happened.
#[test]
fn a_stack_ticking_down_fires_item_lock_changed_before_bag_update() {
use benilla_ui::script::ContainerSlot;
let mut s = UiScript::new().unwrap();
s.run(
"ORDER = {} \
local f = CreateFrame('Frame') \
f:RegisterEvent('ITEM_LOCK_CHANGED') \
f:RegisterEvent('BAG_UPDATE') \
f:SetScript('OnEvent', function() \
table.insert(ORDER, event .. ':' .. tostring(arg1) .. ',' .. tostring(arg2)) \
end)",
)
.unwrap();
let arrows = |count: u32| ContainerSlot {
item_id: 2512, // Rough Arrow
count,
..Default::default()
};
let bag = |s: ContainerSlot| {
HashMap::from([(
0,
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: HashMap::from([(1, s)]),
},
)])
};
let mut memory = FeedMemory::default();
// The quiver arrives full — a create, not a restack. No lock event.
apply_container_source(&mut s, &mut memory, Some(bag(arrows(200))), Vec::new());
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"BAG_UPDATE:0,nil",
"a slot appearing is a create, not a stack-count field write"
);
// An arrow leaves the quiver.
s.run("ORDER = {}").unwrap();
apply_container_source(&mut s, &mut memory, Some(bag(arrows(199))), Vec::new());
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"ITEM_LOCK_CHANGED:0,1 BAG_UPDATE:0,nil",
"the shot signal fires for (bag 0, slot 1) and precedes BAG_UPDATE"
);
// A DIFFERENT item in the same slot: a swap. The count differs too, and it must still
// not fire — otherwise an addon counts a shot every time you rearrange your bags.
s.run("ORDER = {}").unwrap();
let mut other = arrows(20);
other.item_id = 3033; // Razor Arrow
apply_container_source(&mut s, &mut memory, Some(bag(other)), Vec::new());
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"BAG_UPDATE:0,nil",
"a changed entry is a swap, not a stack-count write"
);
}
fn slot(spell_id: u32, charges: i32) -> ItemSpellEntry {
ItemSpellEntry {
index: 0,
spell_id,
trigger: 0,
charges,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
}
}
/// The real builder's charge gate (`0x52da01`/`0x52db51`): the `-1` consume-on-use sentinel
/// prints NO line — the fix for food/water/potions (Tough Hunk of Bread's wire `-1` was
/// rendering "1 Charge") — while a real pool prints its absolute value.
#[test]
fn charge_gate_matches_the_real_builder() {
// Food/water/potions: spellcharges -1 (VERIFIED live vmangos item_template — bread 4540,
// spring water 159, conjured water 5350 all carry `433/430, -1`). No line.
assert_eq!(charges_count(&[slot(433, -1)]), 0, "food's -1 = no line");
// A real charge pool, negative = item destroyed when depleted (Flame Deflector 4376:
// `4057, -5`) — prints the absolute count.
assert_eq!(charges_count(&[slot(4057, -5)]), 5, "wand-style pool");
// A positive pool would print as-is (the abs is a no-op).
assert_eq!(charges_count(&[slot(4057, 3)]), 3);
// Chargeless slots and empty slots: no line.
assert_eq!(charges_count(&[slot(433, 0)]), 0, "template 0 = sentinel");
assert_eq!(charges_count(&[slot(0, -5)]), 0, "no spell = no slot");
assert_eq!(charges_count(&[]), 0);
// The election walks slots: a leading sentinel slot doesn't mask a later pool.
assert_eq!(charges_count(&[slot(433, -1), slot(4057, -10)]), 10);
}
/// The trigger line's **empty-description law**, byte-verified at the item builder
/// (`0x52da29`-`0x52da31`: test the expanded text's first byte, jump past the whole block on
/// zero) — checked against the REAL 5875 Spell.dbc, because the whole point is what the actual
/// data holds.
///
/// The director's report: a dungeon key's tooltip read "Use: Opening". `Opening` is a real
/// spell with a real name and NO description, and the old code fell back to the name.
#[test]
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = crate::ui_action::Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
// Every "Opening"/"Closing" the lock chain can reach — the key spells (3365/3366/6247/6477
// are all literally named "Opening") carry no description, so NO Use: line may be built.
for id in [3365u32, 3366, 6246, 6247, 6477, 21651] {
let d = spells.catalog.get(id).expect("a real Spell.dbc row");
assert!(
!d.name.is_empty(),
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
);
assert_eq!(
super::spell_desc_text(Some(&spells), id, None),
None,
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
d.name
);
}
// The control: a described spell still produces its line, so this is a law about EMPTY
// descriptions and not a blanket mute. Fireball (133) is the tooltip suite's own anchor.
let fireball = super::spell_desc_text(Some(&spells), 133, None)
.expect("a described spell still yields its line");
assert!(
fireball.contains("damage"),
"expected the substituted Fireball description, got {fireball:?}"
);
}
}