- an item is an object in the one index, and its enchant and lifetime countdowns are its own (2334, 2340) - the server's stand state applies, a bag equipped into a bag slot fires BAG_OPEN, and the inspect echo is dropped on purpose (2339) - the schedule census counts declared orders only, and the architecture walls gate leaks rather than the API (2337, 2338) - one queue for every event into the UI is not built: the reference orders its events per mechanism, not by the wire (2341) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1644 lines
77 KiB
Rust
1644 lines
77 KiB
Rust
//! The app-side **character-window feed** (decision 0208 §3/§4): the bridge that turns our own
|
||
//! avatar's descriptor + the item stores into the plain snapshots the paper doll's Lua bindings
|
||
//! read, and into the Era events that drive its `OnEvent` repaints — the [`crate::ui_unit`]
|
||
//! pattern, for the combat-stats/inventory surface.
|
||
//!
|
||
//! Three jobs, each frame, before the VM ticks ([`UiInput`]):
|
||
//!
|
||
//! - **[`UnitCombatStats`]** from the self player's [`ObjectStore`] — the stat/resistance/
|
||
//! damage/attack-power fields (all OWNER_ONLY, verified streamed in 0208) plus the three resolved
|
||
//! skill pairs: the app picks WHICH skill line the equipped main-hand/ranged item uses
|
||
//! (`weapon_subclass_skill` — vmangos `Item.cpp`'s table; no weapon = unarmed 162), reads its
|
||
//! `PLAYER_SKILL_INFO` triplet and the Defense line's ([`SKILL_DEFENSE`]), so the engine-free
|
||
//! bindings just serve the pairs.
|
||
//!
|
||
//! The descriptor half is [`unit_combat_stats`], which any unit's store goes through — the pet
|
||
//! paper doll's feed ([`crate::ui_pet_doll`], decision 1057) is the second caller. What
|
||
//! [`combat_stats`] adds on top is exactly the player-only half: the four values derived from the
|
||
//! equipped items, and the skill pairs, neither of which a creature has.
|
||
//! - **`InventorySlots`** from the inv-slot guids → [`Items`] objects → templates → the
|
||
//! [`ItemDisplays`] icon — index 0 the ammo slot (`PLAYER_AMMO_ID`, its count summed across the
|
||
//! backpack + the four equipped bags), 1..=19 the equipment slots, 20..=23 the four equipped-bag
|
||
//! ICONS (decision 0216 slice 2's bag bar — the bag item itself, `INV_SLOT` 19..22, not its
|
||
//! contents), all by the client's `GetInventorySlotInfo` ids. A slot whose template answer is
|
||
//! still in flight carries its `item_id` with `icon: None` and fills on a later frame (the
|
||
//! ask-once cache repaints via `UNIT_INVENTORY_CHANGED` when the view changes). Each equipment
|
||
//! slot (1..=23) also carries an `|Hitem:…|h[Name]|h` `link`, its `PendingItemOps`-derived
|
||
//! `locked` (decision 0208 phase 1b — the doll twin of `ui_items::feed_containers`'s bag-slot
|
||
//! `.locked`), and its resolved `equip_slots` (`ui_items::find_equip_slot` over the item
|
||
//! template's `inventoryType` — the cursor arc's "fit rule", decision 0216/0218).
|
||
//! - **The paper-doll booth yaw**: the stock pane's rotate buttons call the reference's own
|
||
//! `Model_RotateLeft`/`_RotateRight`, which write `CharacterModelFrame:SetRotation`; the feed
|
||
//! mirrors that pane's facing onto [`PaperDollBooth`] so the booth re-bakes at the new angle
|
||
//! (decision 0208 §5, off the pane itself since 1751).
|
||
//!
|
||
//! Events, fired on snapshot transitions (grouped by the ref's own registration set,
|
||
//! `PaperDollFrame.lua:14-28` — arg1 `"player"`): `UNIT_STATS`, `UNIT_RESISTANCES`, `UNIT_DAMAGE`,
|
||
//! `UNIT_ATTACK_SPEED`, `UNIT_ATTACK_POWER`, `UNIT_RANGED_ATTACK_POWER`, `UNIT_RANGEDDAMAGE`,
|
||
//! `UNIT_ATTACK` (the weapon-skill pairs) — [`fire_stat_transitions`], shared with the pet feed —
|
||
//! and `UNIT_INVENTORY_CHANGED` on any slot-view change. A first snapshot counts as a transition of
|
||
//! every group (the `ui_unit` rule).
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
|
||
use benilla_ui::script::{
|
||
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ScriptValue, SkillEntry,
|
||
SkillsState, UiScript, UnitCombatStats, BANK_BAG_SLOT_COUNT, EQUIPMENT_BAG, SKILL_DEFENSE,
|
||
SKILL_UNARMED,
|
||
};
|
||
use benilla_ui::strings::Arg;
|
||
|
||
use crate::entities::ItemDisplays;
|
||
use crate::items::Items;
|
||
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
|
||
use crate::pending_item_ops::PendingItemOps;
|
||
use crate::portrait::PaperDollBooth;
|
||
use crate::ui_items::{find_equip_slot, item_link};
|
||
use crate::ui_script::{gate, UiFeed, UiInput};
|
||
|
||
/// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): the inv-slot array index of the main hand,
|
||
/// off hand, and ranged slots the weapon-skill / offhand / wand resolutions read.
|
||
const SLOT_MAIN_HAND: u8 = 15;
|
||
const SLOT_OFF_HAND: u8 = 16;
|
||
const SLOT_RANGED: u8 = 17;
|
||
/// `ItemPrototype` class 2 = weapon (vmangos `ItemPrototype.h`); the offhand-speed gate.
|
||
const ITEM_CLASS_WEAPON: u32 = 2;
|
||
/// Weapon subclass 19 = wand (`HasWandEquipped`).
|
||
const SUBCLASS_WAND: u32 = 19;
|
||
|
||
/// A stable comparison key for an f32 snapshot field (the event-transition diffs): quantized to
|
||
/// 1/32 so equal wire values always compare equal and real changes always register.
|
||
fn fixed(v: f32) -> i64 {
|
||
(v * 32.0).round() as i64
|
||
}
|
||
|
||
/// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] shape): the last
|
||
/// snapshots pushed, so events fire on transitions only.
|
||
///
|
||
/// All of it is what-we-told-the-VM, so all of it lives behind a [`crate::ui_script::VmMemo`]: a
|
||
/// memory about one VM is unreadable against the next (1290), so a `/reload` (1291) — which
|
||
/// replaces the VM without despawning the avatar — re-pushes both snapshots and re-fires their
|
||
/// events exactly as a fresh login does.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct CharFeedState {
|
||
vm: crate::ui_script::VmMemo<CharFeedMemo>,
|
||
}
|
||
|
||
/// The per-VM half of [`CharFeedState`] — the transition-diff bases.
|
||
#[derive(Default)]
|
||
struct CharFeedMemo {
|
||
last_stats: Option<UnitCombatStats>,
|
||
last_inv: Option<InventorySlots>,
|
||
last_bank_bags: Option<BankBagSlots>,
|
||
/// The item guid **of the bag itself** in each of the six bank bag slots (absolute player
|
||
/// slots 63..68). `PLAYERBANKSLOTS_CHANGED`'s producer discriminator for this band — the same
|
||
/// question `ui_items::feed`'s `SlotGuids::vault` answers for the vault, and for the same
|
||
/// reason: the pushed view cannot tell two instances of one bag model apart, so a swap of two
|
||
/// identical bags between two slots reads as no change and the reference fires twice
|
||
/// (decision 2140).
|
||
last_bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT],
|
||
/// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed`
|
||
/// for this feed (the equipment templates' ask-once, the enchant-name creator lookups). The
|
||
/// item OBJECTS are not among them since 2334: an instance is an entity, so its edges arrive
|
||
/// on the ordinary `Changed`/removal watch ([`crate::items::ItemChanges`]).
|
||
items_templates: gate::Watch,
|
||
names_generation: gate::Watch,
|
||
/// `ItemChanges::countdown_steps` — one step per displayable countdown change (the slot
|
||
/// views read second-floored countdowns), including the last elapse's collapsing push; a
|
||
/// landing is the item's own change tick (decision 2340).
|
||
enchant_deadlines: gate::Watch,
|
||
/// `PendingItemOps::epoch` — one step per change to the in-flight lock set. Watched BESIDE
|
||
/// `!is_empty()` and not instead of it: `is_empty()` holds the gate open *while* an op is in
|
||
/// flight, and this catches the frame it goes away. Without it a clear that moves no object
|
||
/// (`SMSG_INVENTORY_CHANGE_FAILURE`) closes the gate on the same frame it unlocks, and the
|
||
/// last `locked: true` this feed pushed stands (1771).
|
||
pending_epoch: gate::Watch,
|
||
}
|
||
|
||
/// Adds the per-frame character-window feed. The bindings live in `benilla-ui`'s `char_stats`;
|
||
/// this supplies their data (and the events) from ECS state.
|
||
pub(crate) struct UiCharPlugin;
|
||
|
||
impl Plugin for UiCharPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<CharFeedState>().add_systems(
|
||
Update,
|
||
(
|
||
// `.before(feed_containers)`: the container feed's `BAG_UPDATE` handlers read the
|
||
// inventory surface synchronously — an addon mirroring a bag asks the bag ITEM's
|
||
// own row (`GetInventoryItemLink("player", ContainerIDToInventoryID(bag))`,
|
||
// Bagnon_Forever's size row) — so the inventory push must land first. Unordered,
|
||
// the first in-world burst raced it and recorded every equipped bag linkless
|
||
// (`s = "8,0,"` in the director's Bagnon_Forever data). The house "push before
|
||
// fire" rule, across the two feeds.
|
||
feed_char
|
||
.before(crate::ui_items::feed::feed_containers)
|
||
.in_set(UiFeed),
|
||
watch_skill_ups.in_set(UiFeed),
|
||
feed_skills.in_set(UiFeed),
|
||
drain_skill_abandons.after(UiInput),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The skill block [`watch_skill_ups`] last announced: the self guid it was read off, and that
|
||
/// character's `skillId → value` map — the diff base for both the chat lines and the event.
|
||
/// `None` = nothing announced yet.
|
||
type SkillBlock = Option<(u64, std::collections::HashMap<u16, u16>)>;
|
||
|
||
/// The client-generated skill-up feedback (decision 0437, landed with phase 2; gate corrected by
|
||
/// 1309): the server sends NO skill-up message at all — skills mutate only as
|
||
/// `PLAYER_SKILL_INFO` descriptor deltas (verified at the vmangos source: no chat send anywhere
|
||
/// in `UpdateSkill*`/`SetSkill`) — so the client diff-watches its own skill block exactly as the
|
||
/// real one does (the rank-field UpdateField watcher `0x5de180` → message facility `0x496720`,
|
||
/// tokens `ERR_SKILL_UP_SI` / `ERR_SKILL_GAINED_S` — wow-re tradeskill TU-E, correcting 0437's
|
||
/// "SKILL_RANK_UP" token guess), prints the GlobalStrings lines on the Skill chat channel, and
|
||
/// fires `SKILL_LINES_CHANGED` on ANY block change (the skills pane's repaint event; TU-E: the
|
||
/// real client fires it from a separate skill-manager TU on add/remove only — ours riding the
|
||
/// same watcher is a benign coarsening, the pane just repaints). **The message gate** (B19/B245,
|
||
/// decisions 1309/1314): both lines are skipped when the line's `SkillRaceClassInfo.flags`
|
||
/// carries `0x402` (`SkillRaceClass::skill_up_silent`) — the class spec lines, racials,
|
||
/// `GENERIC (DND)`, `Dual Wield` and the mount lines — **or when no row admits this race/class
|
||
/// at all** (the resolve's own empty branch, `0x5de352`) — which is why a real 1.12 ding
|
||
/// announces no skill at all (the level-up movers are all flagged) while weapon/profession
|
||
/// skill-ups still print. The
|
||
/// login fill (empty → populated) and a character switch (self guid change) re-seed silently —
|
||
/// the event fires, the lines don't. Still open: the exact chat channel of the real emitter
|
||
/// (TU-E left `0x496720`'s routing untraced; Skill is the `ChatTypeInfo` family's own key).
|
||
/// One skill-watcher line: resolve the key off the player's own `GlobalStrings.lua`, fill it, and
|
||
/// push it as `CHAT_MSG_SKILL`.
|
||
///
|
||
/// **Deliberately not `ui_action::show_messages`, and the reason is a real gap rather than a
|
||
/// preference.** `ERR_SKILL_UP_SI`/`ERR_SKILL_GAINED_S` (rows 54/53) are the two of the three
|
||
/// catalog rows whose `chat_type` is **23 = `CHAT_MSG_SKILL`**, not the `10 = CHAT_MSG_SYSTEM`
|
||
/// every other `kind 0` row carries — and that field is exactly what the shared sink does not
|
||
/// read yet (its own doc says so: "benilla raises none of them yet; when it does, this is the
|
||
/// line that has to read the field"). Routing these through it today would silently demote them
|
||
/// to SYSTEM and out of the Skill filter. So the key, the fill and the data-suppression rule are
|
||
/// the shared ones; only the surface is carried here, until the sink learns `chat_type`.
|
||
///
|
||
/// An absent or empty key shows nothing — the reference's own null/empty guard.
|
||
fn skill_line(script: &UiScript, chat: &mut crate::ui_chat::ChatLog, key: &str, args: &[Arg<'_>]) {
|
||
let Some(template) = script
|
||
.lua()
|
||
.globals()
|
||
.get::<String>(key)
|
||
.ok()
|
||
.filter(|t| !t.is_empty())
|
||
else {
|
||
return;
|
||
};
|
||
chat.push_event(crate::ui_chat::ChatEvent::text_only(
|
||
crate::ui_chat::ChatEventKind::Skill,
|
||
benilla_ui::strings::fill(&template, args),
|
||
));
|
||
}
|
||
|
||
fn watch_skill_ups(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
self_guid: Res<crate::net::SelfGuid>,
|
||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||
mut prev: Local<crate::ui_script::VmMemo<SkillBlock>>,
|
||
) {
|
||
let (Some(guid), Ok(store)) = (self_guid.0, self_store.single()) else {
|
||
return;
|
||
};
|
||
// The memo is about the VM — it gates `SKILL_LINES_CHANGED`, the pane's only repaint wire — so
|
||
// it re-seeds with the VM (decision 1290). With no VM there is nothing to announce to and
|
||
// nothing worth remembering: the diff would only queue chat lines for a VM that does not exist
|
||
// yet, which is a stale skill-up replayed at the next login. The next VM re-seeds silently.
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let prev = prev.get(&script);
|
||
let mut cur = std::collections::HashMap::new();
|
||
for i in 0..PLAYER_SKILL_SLOTS {
|
||
if let Some(s) = store.0.player_skill(i) {
|
||
if s.skill_id != 0 {
|
||
cur.insert(s.skill_id, s.value);
|
||
}
|
||
}
|
||
}
|
||
match prev.as_ref() {
|
||
// First fill for this character: seed silently (no lines), announce the block once.
|
||
None => {
|
||
if cur.is_empty() {
|
||
return;
|
||
}
|
||
script.fire_event("SKILL_LINES_CHANGED", vec![]);
|
||
*prev = Some((guid, cur));
|
||
}
|
||
Some((prev_guid, _)) if *prev_guid != guid => {
|
||
// A different character logged in — re-seed, never diff across characters.
|
||
script.fire_event("SKILL_LINES_CHANGED", vec![]);
|
||
*prev = Some((guid, cur));
|
||
}
|
||
Some((_, prev_map)) => {
|
||
if *prev_map == cur {
|
||
return;
|
||
}
|
||
// The 0x402 message gate resolves through the player's own race/class row, the same
|
||
// lookup the pane's display law makes ([`skills_row`]).
|
||
let (race, class) = (
|
||
store.0.unit_race().unwrap_or(0),
|
||
store.0.unit_class().unwrap_or(0),
|
||
);
|
||
for (&id, &value) in &cur {
|
||
let line_id = u32::from(id);
|
||
let name = || {
|
||
skill_lines
|
||
.as_ref()
|
||
.and_then(|s| s.catalog.line(line_id))
|
||
.map(|l| l.name.clone())
|
||
};
|
||
// The fn doc's message gate; `false` with no catalog (nothing to name it by
|
||
// either — the line stays silent, as before the catalog loads).
|
||
let announces = || {
|
||
skill_lines
|
||
.as_ref()
|
||
.is_some_and(|s| s.catalog.announces_skill_ups(line_id, race, class))
|
||
};
|
||
match prev_map.get(&id) {
|
||
// A rank-up: `ERR_SKILL_UP_SI`, catalog row 54 — **not** `SKILL_RANK_UP`,
|
||
// which is the same enUS sentence and no catalog row at all (decision 2045).
|
||
Some(&old) if value > old => {
|
||
if let Some(name) = name() {
|
||
// Both verdicts are logged — the retest's instrument: a moved line
|
||
// either announces or names the gate that held it.
|
||
if announces() {
|
||
debug!("chat: skill-up announced ({name} {old}→{value})");
|
||
skill_line(
|
||
&script,
|
||
&mut chat,
|
||
"ERR_SKILL_UP_SI",
|
||
&[Arg::S(&name), Arg::D(i64::from(value))],
|
||
);
|
||
} else {
|
||
debug!("chat: skill-up silenced ({name} {old}→{value}, the 0x402 gate)");
|
||
}
|
||
}
|
||
}
|
||
// A NEW line: the ERR_SKILL_GAINED_S line (GlobalStrings.lua:1837) — the
|
||
// fold-back's second token (wow-re `tradeskill` TU-E: the watcher emits
|
||
// both, first-gain vs rank-up).
|
||
None => {
|
||
if let Some(name) = name() {
|
||
if announces() {
|
||
debug!("chat: skill-gain announced ({name} at {value})");
|
||
skill_line(
|
||
&script,
|
||
&mut chat,
|
||
"ERR_SKILL_GAINED_S",
|
||
&[Arg::S(&name)],
|
||
);
|
||
} else {
|
||
debug!(
|
||
"chat: skill-gain silenced ({name} at {value}, the 0x402 gate)"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
script.fire_event("SKILL_LINES_CHANGED", vec![]);
|
||
*prev = Some((guid, cur));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The skills-pane feed (decision 0437 phase 4, corrected by 1091): every `PLAYER_SKILL_INFO` line
|
||
/// the real client would LIST, resolved to a flat [`SkillEntry`] — name off `SkillLine.dbc`, group
|
||
/// off the line's `categoryId` × `SkillLineCategory.dbc` (name + displayOrder) — pushed via
|
||
/// `set_skills` on change; the ENGINE groups/sorts/folds.
|
||
///
|
||
/// The inclusion predicate is the client's own list build (`0x4d2cb0`, wow-re
|
||
/// `system/tradeskill/scratch/skillframe-display-list.md`), transcribed in its order: a line needs
|
||
/// a `SkillLine.dbc` row, an admitting `SkillRaceClassInfo` row and a real `SkillLineCategory` row;
|
||
/// `flags & 0x2` drops it outright; and a line held at **rank 0** appears only if its flags admit
|
||
/// it at this player's level. Note what is NOT a filter: the `Not Displayed` category (12). We used
|
||
/// to hide on it, which happened to hide `GENERIC (DND)` for the right-looking reason — the client
|
||
/// drops that line by its `0x2` bit like `Dual Wield` and the racials, and lists a category-12 line
|
||
/// without `0x2` under its own header (decision 1091).
|
||
fn feed_skills(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
self_store: Query<Ref<ObjectStore>, With<SelfPlayer>>,
|
||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||
mut last: Local<crate::ui_script::VmMemo<Option<SkillsState>>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let last = last.get(&script);
|
||
let lines_moved = skill_lines.as_ref().is_some_and(|l| l.is_changed());
|
||
let (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else {
|
||
return;
|
||
};
|
||
// The list is a pure function of our descriptor and the catalog: with both still and a
|
||
// push already made on this VM, nothing below can differ from what the memo holds. It used
|
||
// to build ~40 rows with a cloned name each, every frame, to compare and drop them.
|
||
if last.is_some() && !store.is_changed() && !lines_moved {
|
||
return;
|
||
}
|
||
// The display predicate reads the player's own race/class (the SkillRaceClassInfo row-match —
|
||
// the spellbook's own General-collapse inputs, `ui_spellbook::build_book`) and level (the
|
||
// untrained gate).
|
||
let (race, class) = (
|
||
store.0.unit_race().unwrap_or(0),
|
||
store.0.unit_class().unwrap_or(0),
|
||
);
|
||
let level = store.0.unit_level().unwrap_or(0);
|
||
let mut entries = Vec::new();
|
||
for i in 0..PLAYER_SKILL_SLOTS {
|
||
let Some(slot) = store.0.player_skill(i) else {
|
||
continue;
|
||
};
|
||
if let Some(e) = skills_row(&slot, &skill_lines.catalog, race, class, level) {
|
||
entries.push(e);
|
||
}
|
||
}
|
||
let fresh = SkillsState { entries };
|
||
if last.as_ref() == Some(&fresh) {
|
||
return;
|
||
}
|
||
script.set_skills(fresh.clone());
|
||
*last = Some(fresh);
|
||
}
|
||
|
||
/// One `PLAYER_SKILL_INFO` slot as the Skills tab's row — or `None` when the real client's list
|
||
/// build would not list it at all ([`feed_skills`]'s doc for the citations; the order of the tests
|
||
/// is the client's own, `0x4d2cb0`). Pure, so the whole display predicate is testable against real
|
||
/// DBC data and a real character's skill block (`ui_script::skills_frame_tests`).
|
||
pub(crate) fn skills_row(
|
||
slot: &benilla_protocol::messages::PlayerSkillSlot,
|
||
catalog: &benilla_formats::SkillLineCatalog,
|
||
race: u8,
|
||
class: u8,
|
||
level: u32,
|
||
) -> Option<SkillEntry> {
|
||
if slot.skill_id == 0 {
|
||
return None;
|
||
}
|
||
let id = u32::from(slot.skill_id);
|
||
// No SkillLine row — nothing to name it by, and the client lists nothing.
|
||
let line = catalog.line(id)?;
|
||
// No row admitting this race/class → the client drops the line (its `!srci → continue`).
|
||
let rc = catalog.race_class(id, race, class)?;
|
||
// A category that must really exist (the client's own null-row test) — and note the category
|
||
// ITSELF is never a filter: `Not Displayed` (12) is a header like any other.
|
||
let (category_name, category_order) = catalog.category(line.category_id)?;
|
||
// The hide bit: `Dual Wield`, the racials, the mount lines, `GENERIC (DND)`.
|
||
if rc.hidden() {
|
||
return None;
|
||
}
|
||
// An untrained line shows only where the flags admit it at this level.
|
||
if slot.value == 0 && !rc.displays_untrained(level) {
|
||
return None;
|
||
}
|
||
Some(SkillEntry {
|
||
skill_id: id,
|
||
name: line.name.clone(),
|
||
value: u32::from(slot.value),
|
||
max: u32::from(slot.max),
|
||
temp_bonus: i32::from(slot.temp_bonus),
|
||
perm_bonus: i32::from(slot.perm_bonus),
|
||
min_level: rc.min_level,
|
||
cost_index: rc.cost_index,
|
||
category_id: line.category_id,
|
||
category_name: category_name.to_string(),
|
||
category_order,
|
||
description: line.description.clone(),
|
||
// The client ANDs the DBC's unlearnable bit with a nonzero skill STEP (`0x4d3953`).
|
||
abandonable: slot.step != 0 && rc.unlearnable(),
|
||
mono: rc.mono(),
|
||
})
|
||
}
|
||
|
||
/// The abandon drain (the skills pane's unlearn button → the `UNLEARN_SKILL` popup's accept →
|
||
/// the engine's `AbandonSkill` queue): each queued skill line id becomes one
|
||
/// `CMSG_UNLEARN_SKILL`. Nothing else happens client-side — the server's `SetSkill(id, 0, 0)`
|
||
/// returns as a `PLAYER_SKILL_INFO` delta and [`feed_skills`] re-pushes the shrunken list
|
||
/// (vmangos `SkillHandler.cpp`; the gossip/trainer drains' own shape).
|
||
fn drain_skill_abandons(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for skill_id in script.take_skill_abandons() {
|
||
debug!("ui_char: abandon skill line {skill_id}");
|
||
let _ = commands.0.send(ClientCommand::UnlearnSkill { skill_id });
|
||
}
|
||
}
|
||
|
||
/// Resolve one equipped slot's item template entry (`None` = empty slot, or the item object /
|
||
/// template hasn't streamed yet).
|
||
fn slot_entry(store: &ObjectStore, objects: &Objects, slot0: u8) -> Option<u32> {
|
||
let guid = store.0.player_inv_slot(slot0)?;
|
||
objects.object(guid)?.object_entry()
|
||
}
|
||
|
||
/// The equipped item's weapon-skill line id: the vmangos `Item.cpp` subclass table for a class-2
|
||
/// item, unarmed (162) for an empty hand or a non-weapon / unmapped subclass.
|
||
fn weapon_skill_id(
|
||
store: &ObjectStore,
|
||
objects: &Objects,
|
||
items: &Items,
|
||
commands: &NetCommands,
|
||
slot0: u8,
|
||
) -> u32 {
|
||
let Some(entry) = slot_entry(store, objects, slot0) else {
|
||
return SKILL_UNARMED;
|
||
};
|
||
let Some(t) = items.template(entry, 0, commands) else {
|
||
return SKILL_UNARMED; // in flight — refined next frame when the template lands
|
||
};
|
||
if t.class != ITEM_CLASS_WEAPON {
|
||
return SKILL_UNARMED;
|
||
}
|
||
weapon_subclass_skill(t.subclass).unwrap_or(SKILL_UNARMED)
|
||
}
|
||
|
||
/// Read a skill line's `(value + PERM bonus, TEMP bonus)` pair from the `PLAYER_SKILL_INFO`
|
||
/// triplets; `(0, 0)` when the line isn't known (a ranged pair with nothing equipped).
|
||
///
|
||
/// **The split is not `(value, temp + perm)`, which is what this returned until decision 1812.**
|
||
/// The reader every skill-shaped binding goes through is `0x5ea460`, and it writes
|
||
/// `*out1 = value + permBonus` / `*out2 = tempBonus` — the permanent half folds into the BASE and
|
||
/// only the temporary half is the modifier (VERIFIED, wow-re
|
||
/// `ui/scratch/unitrangedattack-skill-pair.md` §3; the field reads are `+0x84C` `movzx` value,
|
||
/// `+0x850` `movsx` temp — the block's only sign-extended read — and `+0x852` `movzx` perm).
|
||
///
|
||
/// The old shape had the right TOTAL and the wrong halves, which is why nothing caught it: the
|
||
/// paper doll's Attack row prints `base` and colours `base + mod`, so a 300-value line with a +5
|
||
/// talent and a +10 aura read "300" with a green +15 where the reference reads "305" with a green
|
||
/// +10. wow-re's own `object-layer/scratch/w2b2.md` had the two inverted as well, and that is
|
||
/// where ours came from; the same round corrected it there.
|
||
///
|
||
/// **The perm add is skipped when the value is 0** (`0x5ea4b4 jle`), so an unknown line stays a
|
||
/// flat 0 rather than reading back a bare talent bonus.
|
||
///
|
||
/// Signed, because the sum is: `fild dword` on both pushes, and a perm malus deeper than the skill
|
||
/// is representable on the wire even if no live server sends one.
|
||
fn skill_pair(store: &ObjectStore, skill_id: u32) -> (i32, i32) {
|
||
for i in 0..PLAYER_SKILL_SLOTS {
|
||
if let Some(s) = store.0.player_skill(i) {
|
||
if u32::from(s.skill_id) == skill_id {
|
||
let value = i32::from(s.value);
|
||
let base = if value == 0 {
|
||
0
|
||
} else {
|
||
value + i32::from(s.perm_bonus)
|
||
};
|
||
return (base, i32::from(s.temp_bonus));
|
||
}
|
||
}
|
||
}
|
||
(0, 0)
|
||
}
|
||
|
||
/// Build the **descriptor-only** combat-stats snapshot for ANY unit's store (field mappings pinned
|
||
/// in decision 0208; the absent shapes are the descriptor's zero defaults, `percent` 1.0).
|
||
///
|
||
/// Nothing here reads inventory, templates or skill lines — so it is exactly the part a pet can go
|
||
/// through ([`crate::ui_pet_doll`], decision 1057). The `player_*` reads are deliberately left in:
|
||
/// a creature has no PLAYER block, so they read absent and fall to their defaults, which is the
|
||
/// right pet answer (no buff decomposition, `damage_percent` the divide-safe 1.0) rather than a
|
||
/// second unit-only copy of the same twenty lines.
|
||
///
|
||
/// **That premise is load-bearing, and it was false until decision 1081.** A created store used to
|
||
/// answer `Some(0)` for *any* absent index, PLAYER block or not, so on a live pet every default
|
||
/// here was dead code — `damage_percent` came through as `0` and the ref's `damage / percent` made
|
||
/// the pet sheet read `inf - inf` / `nan`. The protocol layer now bounds "absent = 0" to the
|
||
/// object's own descriptor, which is what makes this shared core honest for a creature.
|
||
/// A swing time in ms, with `0` (and absent) read as "no swing time yet" → the vanilla 2000. Never
|
||
/// zero, because it is the reference's `damage / speed` divisor.
|
||
fn base_swing(streamed: Option<u32>) -> u32 {
|
||
streamed.filter(|&ms| ms != 0).unwrap_or(2000)
|
||
}
|
||
|
||
pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
|
||
let f = &store.0;
|
||
|
||
let mut stats = [0i32; 5];
|
||
let mut stat_pos = [0i32; 5];
|
||
let mut stat_neg = [0i32; 5];
|
||
for i in 0..5u8 {
|
||
stats[usize::from(i)] = f.unit_stat(i).unwrap_or(0) as i32;
|
||
// The four buff-split arrays are INT on the wire (decision 1397) — they used to be read as
|
||
// f32 and rounded, which turned every real value into `0` and is why B165/B251's stats
|
||
// never went green.
|
||
stat_pos[usize::from(i)] = f.player_posstat(i).unwrap_or(0);
|
||
stat_neg[usize::from(i)] = f.player_negstat(i).unwrap_or(0);
|
||
}
|
||
let mut resistances = [0i32; 7];
|
||
let mut resistance_pos = [0i32; 7];
|
||
let mut resistance_neg = [0i32; 7];
|
||
for i in 0..7u8 {
|
||
resistances[usize::from(i)] = f.unit_resistance(i).unwrap_or(0);
|
||
resistance_pos[usize::from(i)] = f.player_resistance_buff_pos(i).unwrap_or(0);
|
||
resistance_neg[usize::from(i)] = f.player_resistance_buff_neg(i).unwrap_or(0);
|
||
}
|
||
|
||
let (ap_pos, ap_neg) = f.unit_attack_power_mods();
|
||
let (rap_pos, rap_neg) = f.unit_ranged_attack_power_mods();
|
||
|
||
UnitCombatStats {
|
||
stats,
|
||
stat_pos,
|
||
stat_neg,
|
||
resistances,
|
||
resistance_pos,
|
||
resistance_neg,
|
||
min_damage: f.unit_min_damage().unwrap_or(0.0),
|
||
max_damage: f.unit_max_damage().unwrap_or(0.0),
|
||
min_offhand_damage: f.unit_min_offhand_damage().unwrap_or(0.0),
|
||
max_offhand_damage: f.unit_max_offhand_damage().unwrap_or(0.0),
|
||
physical_bonus_pos: f.player_mod_damage_done_pos(0).unwrap_or(0),
|
||
physical_bonus_neg: f.player_mod_damage_done_neg(0).unwrap_or(0),
|
||
damage_percent: f.player_mod_damage_done_pct(0).unwrap_or(1.0),
|
||
// 2000 ms is the vanilla base swing when the field hasn't streamed — keeps the ref's
|
||
// damage/speed division finite on the first frames. **`0` counts as unstreamed**, not as a
|
||
// swing time: on a created store an absent in-block field reads `Some(0)` (the descriptor's
|
||
// zero), so a plain `unwrap_or` would never fire and `damage / speed` would go to infinity
|
||
// (the shape of the bug decision 1081 is about, one field over).
|
||
main_attack_time_ms: base_swing(f.unit_base_attack_time(0)),
|
||
offhand_attack_time_ms: base_swing(f.unit_base_attack_time(1)),
|
||
attack_power: f.unit_attack_power().unwrap_or(0),
|
||
attack_power_pos: i32::from(ap_pos),
|
||
attack_power_neg: i32::from(ap_neg),
|
||
ranged_attack_power: f.unit_ranged_attack_power().unwrap_or(0),
|
||
ranged_attack_power_pos: i32::from(rap_pos),
|
||
ranged_attack_power_neg: i32::from(rap_neg),
|
||
ranged_attack_time_ms: base_swing(f.unit_ranged_attack_time()),
|
||
ranged_min_damage: f.unit_min_ranged_damage().unwrap_or(0.0),
|
||
ranged_max_damage: f.unit_max_ranged_damage().unwrap_or(0.0),
|
||
// The equipment- and skill-derived half is the PLAYER feed's ([`combat_stats`]); a unit
|
||
// with neither keeps these defaults.
|
||
has_offhand: false,
|
||
has_wand: false,
|
||
main_weapon_skill: (0, 0),
|
||
offhand_weapon_skill: (0, 0),
|
||
ranged_weapon_skill: (0, 0),
|
||
defense_skill: (0, 0),
|
||
// Player fields — the player's own snapshot fills them (`combat_stats`); a pet has none.
|
||
dodge_percent: 0.0,
|
||
parry_percent: 0.0,
|
||
block_percent: 0.0,
|
||
}
|
||
}
|
||
|
||
/// The player's snapshot: [`unit_combat_stats`] plus the half only *we* have — the two values that
|
||
/// need the equipped items' templates, and the three `PLAYER_SKILL_INFO` pairs.
|
||
fn combat_stats(
|
||
store: &ObjectStore,
|
||
objects: &Objects,
|
||
items: &Items,
|
||
commands: &NetCommands,
|
||
) -> UnitCombatStats {
|
||
// The offhand-speed gate is an offhand *weapon* (a shield doesn't swing); the wand check is
|
||
// the ranged item's subclass. Both read the equipped item's template (ask-once in flight →
|
||
// false this frame, refined when it lands).
|
||
let has_offhand = slot_entry(store, objects, SLOT_OFF_HAND)
|
||
.and_then(|e| items.template(e, 0, commands))
|
||
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON);
|
||
let has_wand = slot_entry(store, objects, SLOT_RANGED)
|
||
.and_then(|e| items.template(e, 0, commands))
|
||
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON && t.subclass == SUBCLASS_WAND);
|
||
|
||
let main_skill = weapon_skill_id(store, objects, items, commands, SLOT_MAIN_HAND);
|
||
let offhand_skill = weapon_skill_id(store, objects, items, commands, SLOT_OFF_HAND);
|
||
let ranged_skill_id = slot_entry(store, objects, SLOT_RANGED)
|
||
.and_then(|e| items.template(e, 0, commands))
|
||
.filter(|t| t.class == ITEM_CLASS_WEAPON)
|
||
.and_then(|t| weapon_subclass_skill(t.subclass));
|
||
|
||
UnitCombatStats {
|
||
has_offhand,
|
||
has_wand,
|
||
main_weapon_skill: skill_pair(store, main_skill),
|
||
// `UnitAttackBothHands`'s SECOND pair (decision 1810) — the binding pushes one per hand,
|
||
// and `weapon_skill_id` already answers Unarmed for an empty or non-weapon off hand, which
|
||
// is what a shield or an empty hand reads as.
|
||
offhand_weapon_skill: skill_pair(store, offhand_skill),
|
||
ranged_weapon_skill: ranged_skill_id.map_or((0, 0), |id| skill_pair(store, id)),
|
||
// `UnitDefense`'s pair. Its repaint wire is `SKILL_LINES_CHANGED` (which the ref's
|
||
// `PaperDollFrame` registers, l.28, and `watch_skill_ups` already fires), NOT a
|
||
// `UNIT_DEFENSE` — the character sheet never registers one.
|
||
defense_skill: skill_pair(store, SKILL_DEFENSE),
|
||
// `GetDodgeChance`/`GetParryChance`/`GetBlockChance` — player fields, so they live on the
|
||
// player's snapshot and not on the shared core a pet also fills. Already a percent on the
|
||
// wire; an unstreamed field is 0, which is the wire's own default.
|
||
dodge_percent: store.0.player_dodge_percentage().unwrap_or(0.0),
|
||
parry_percent: store.0.player_parry_percentage().unwrap_or(0.0),
|
||
block_percent: store.0.player_block_percentage().unwrap_or(0.0),
|
||
..unit_combat_stats(store)
|
||
}
|
||
}
|
||
|
||
/// One equipped slot's view: entry + count from the item object, icon/name/quality/link/
|
||
/// equip_slots from the ask-once template (icon `None` while the answer is in flight — the
|
||
/// empty-slot art shows and the landing template flips the view, which fires
|
||
/// `UNIT_INVENTORY_CHANGED`), `locked` from the app's `PendingItemOps` (decision 0208 phase 1b —
|
||
/// the same feed `ui_items::feed_containers` reads for a bag slot's own `.locked`).
|
||
///
|
||
/// Takes the item's **guid and its live-API id** rather than a wire slot index, because the two
|
||
/// bands that use it resolve their guid out of two different descriptor arrays: the doll's
|
||
/// `PLAYER_FIELD_INV_SLOT_*` (live id = wire slot + 1, so equipment 1..=19 and the four
|
||
/// equipped-bag icons 20..=23) and the bank bags' own `PLAYER_FIELD_BANK_BAG_SLOT_*` (live ids
|
||
/// 64..=69). Everything past the guid — template, enchants, durability, the pending lock — is
|
||
/// identical for both, which is the whole reason there is one function here and not two.
|
||
fn slot_view(
|
||
objects: &Objects,
|
||
items: &Items,
|
||
icons: Option<&ItemDisplays>,
|
||
rolls: crate::items::RollCatalogs,
|
||
commands: &NetCommands,
|
||
pending: &PendingItemOps,
|
||
names: &crate::names::NameCache,
|
||
// `ItemSubClass.dbc` — the count gate below reads its `DisplayFlags` bit 2. `None` (the DBC
|
||
// failed to load) leaves every bag at 0, which is the plain-bag answer and the safe one.
|
||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||
// The player's own `ChrClasses.dbc` relic flag — `find_equip_slot`'s second half (1803). It
|
||
// decides INVSLOT 17 both ways, so it reaches every fit-rule read rather than one of them.
|
||
has_relic_slot: bool,
|
||
guid: u64,
|
||
live_id: u32,
|
||
) -> Option<InvSlotView> {
|
||
// The item's own countdown cells — the bag feed's twin (decisions 1933, 2340).
|
||
let countdowns = objects.countdowns(guid);
|
||
let enchant_ms: [Option<u64>; crate::items::ENCHANT_SLOTS] =
|
||
std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32)));
|
||
let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms());
|
||
let obj = objects.object(guid)?;
|
||
let entry = obj.object_entry()?;
|
||
let count = obj.item_stack_count().unwrap_or(1).max(1);
|
||
// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER` — the fork `GetInventoryItemCount` takes first
|
||
// (`0x4c87a6`), read off the object exactly as the reference reads it rather than inferred
|
||
// from the template.
|
||
let is_container = obj.is_container();
|
||
// A worn container's contents, collected while the bag object is borrowed — summed below if
|
||
// its subclass row carries the count gate.
|
||
let content_guids: Vec<u64> = {
|
||
let slots = obj.container_num_slots().unwrap_or(0).min(36) as u8;
|
||
(0..slots).filter_map(|s| obj.container_slot(s)).collect()
|
||
};
|
||
// The live durability pair (see the bag feed's twin) — the equipped tooltip's line.
|
||
let durability = obj
|
||
.item_durability()
|
||
.zip(obj.item_max_durability())
|
||
.filter(|&(_, max)| max > 0);
|
||
// ITEM_FIELD_FLAGS — the broken/alert laws' wrapped (0x08) and force-red (0x10) bits.
|
||
let flags = obj.item_flags().unwrap_or(0);
|
||
// `0x5da2c0` — soulbound, or carrying a binding enchant: the equipped tooltip's §6 Soulbound
|
||
// override (B310 — the doll is exactly where it was reported). Read off the raw descriptor,
|
||
// never off the enchant LINES below.
|
||
let already_bound = crate::items::already_bound(obj, rolls.enchants);
|
||
// ITEM_FIELD_CREATOR → the ask-once name cache — the equipped tooltip's "<Made by %s>"
|
||
// line (see the bag feed's twin in `ui_items::feed::resolve_slot`).
|
||
let creator = obj
|
||
.item_creator()
|
||
.filter(|&g| g != 0)
|
||
.and_then(|g| names.resolve(g, commands).map(str::to_string));
|
||
// The item's own 7 enchant slots — the equipped tooltip's enchant lines (decision 0915). OUR
|
||
// gear streams as item objects, so this is the full array; an INSPECTED player's tooltip sees
|
||
// only the 2 slots their descriptor broadcasts (`ui_inspect`), as the reference does.
|
||
let enchants = crate::items::enchant_lines(
|
||
(0..7).map(|s| {
|
||
(
|
||
s,
|
||
obj.item_enchant(s).unwrap_or(0),
|
||
obj.item_enchant_charges(s),
|
||
enchant_ms[usize::from(s)],
|
||
)
|
||
}),
|
||
rolls.enchants,
|
||
);
|
||
// `ITEM_FIELD_RANDOM_PROPERTIES_ID` — the roll behind the NAME's "of the Bear" (1547); the
|
||
// roll's enchants are already in the slots read just above.
|
||
let roll = obj.item_random_properties_id();
|
||
let t = items.template(entry, guid, commands);
|
||
let (name, quality, display, link, equip_slots, kind, bar_placeable) = match t {
|
||
Some(t) => {
|
||
let name = rolls.name(&t.name, roll);
|
||
(
|
||
Some(name.clone()),
|
||
t.quality as i32,
|
||
t.display_info_id,
|
||
Some(crate::ui_items::item_link_full(
|
||
entry, 0, roll, 0, &name, t.quality,
|
||
)),
|
||
find_equip_slot(t.inventory_type, has_relic_slot),
|
||
(t.class, t.subclass),
|
||
t.placeable_on_action_bar(),
|
||
)
|
||
}
|
||
None => (None, 0, 0, None, Vec::new(), (0, 0), false),
|
||
};
|
||
// **A container's count is not its stack count** — `GetInventoryItemCount 0x4c8680` forks on
|
||
// the TYPEMASK bit and then on `ItemSubClass.dbc`'s `DisplayFlags & 0x4` (`0x4c881a`): set →
|
||
// `[CGContainer vtbl+0x10]` → `0x622130`, the sum of the contents' stack counts; clear → 0.
|
||
// In the shipped file that bit is on Soul Bag (1/1) and the Quiver class (11/0..3) only, so a
|
||
// quiver shows its arrows on the bag bar and an ordinary bag shows nothing at all.
|
||
//
|
||
// This REPLACES a `class == 11` guess whose own comment admitted it was inferred from what the
|
||
// reference UI looked like, and which was wrong twice over: it missed the soul bag, and it
|
||
// answered a plain bag's stack count (1) where the client answers 0 — the digit the director
|
||
// saw on `BankFrameBag1` after 1751's swap put a real `isBag` button in front of it.
|
||
let counts_contents = sub_classes.is_some_and(|c| c.0.display_flags(kind.0, kind.1) & 0x4 != 0);
|
||
let contents_count = is_container.then(|| {
|
||
if counts_contents {
|
||
content_guids
|
||
.iter()
|
||
.filter_map(|&g| objects.object(g))
|
||
.map(|o| o.item_stack_count().unwrap_or(1).max(1))
|
||
.sum()
|
||
} else {
|
||
0
|
||
}
|
||
});
|
||
let icon = icons
|
||
.and_then(|i| i.catalog.get(display))
|
||
.and_then(|d| d.icon.clone());
|
||
Some(InvSlotView {
|
||
item_id: entry,
|
||
icon,
|
||
count,
|
||
contents_count,
|
||
quality,
|
||
name,
|
||
link,
|
||
durability,
|
||
flags,
|
||
already_bound,
|
||
locked: pending.contains(EQUIPMENT_BAG, live_id),
|
||
equip_slots,
|
||
bar_placeable,
|
||
creator,
|
||
enchants,
|
||
duration_ms,
|
||
})
|
||
}
|
||
|
||
/// Total carried ammo matching `ammo_id`: the backpack's 16 slots + the four equipped bags'
|
||
/// contents (`GetInventoryItemCount("player", ammo)` counts what you can actually shoot — bank
|
||
/// and buyback never join).
|
||
fn ammo_count(store: &ObjectStore, objects: &Objects, ammo_id: u32) -> u32 {
|
||
let mut n = 0;
|
||
let mut add = |guid: Option<u64>| {
|
||
if let Some(o) = guid.and_then(|g| objects.object(g)) {
|
||
if o.object_entry() == Some(ammo_id) {
|
||
n += o.item_stack_count().unwrap_or(1);
|
||
}
|
||
}
|
||
};
|
||
for i in 0..16u8 {
|
||
add(store.0.player_pack_slot(i));
|
||
}
|
||
for b in 19..23u8 {
|
||
if let Some(bag) = store.0.player_inv_slot(b).and_then(|g| objects.object(g)) {
|
||
let slots = bag.container_num_slots().unwrap_or(0).min(36) as u8;
|
||
for s in 0..slots {
|
||
if let Some(o) = bag.container_slot(s).and_then(|g| objects.object(g)) {
|
||
if o.object_entry() == Some(ammo_id) {
|
||
n += o.item_stack_count().unwrap_or(1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
/// Build the 24-wide inventory snapshot: `[0]` ammo, `[1..=19]` the equipment slots, `[20..=23]`
|
||
/// the four equipped-bag icons (decision 0216 slice 2's bag bar — the bag ITEM occupying `INV_SLOT`
|
||
/// 19..22, not its contents) — all the client's 1-based `GetInventorySlotInfo` ids over the
|
||
/// 0-based inv-slot array.
|
||
fn inventory_slots(
|
||
store: &ObjectStore,
|
||
objects: &Objects,
|
||
items: &Items,
|
||
icons: Option<&ItemDisplays>,
|
||
rolls: crate::items::RollCatalogs,
|
||
commands: &NetCommands,
|
||
pending: &PendingItemOps,
|
||
names: &crate::names::NameCache,
|
||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||
classes: Option<&benilla_formats::ChrClasses>,
|
||
) -> InventorySlots {
|
||
// Resolved once from the player's own class byte, right here — the store is already in scope,
|
||
// so nothing new is threaded through the callers for the class half.
|
||
let has_relic_slot = store
|
||
.0
|
||
.unit_class()
|
||
.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c))));
|
||
let mut inv: InventorySlots = Default::default();
|
||
let ammo_id = store.0.player_ammo_id().unwrap_or(0);
|
||
if ammo_id != 0 {
|
||
let t = items.template(ammo_id, 0, commands);
|
||
let (name, quality, display, link) = match t {
|
||
Some(t) => (
|
||
Some(t.name.clone()),
|
||
t.quality as i32,
|
||
t.display_info_id,
|
||
Some(item_link(ammo_id, &t.name, t.quality)),
|
||
),
|
||
None => (None, 0, 0, None),
|
||
};
|
||
let icon = icons
|
||
.and_then(|i| i.catalog.get(display))
|
||
.and_then(|d| d.icon.clone());
|
||
inv[0] = Some(InvSlotView {
|
||
durability: None,
|
||
flags: 0,
|
||
// Ammo is the one doll slot with no item OBJECT behind it (it is built from the
|
||
// template alone), so the runtime-bind predicate has nothing to read.
|
||
already_bound: false,
|
||
item_id: ammo_id,
|
||
icon,
|
||
count: ammo_count(store, objects, ammo_id),
|
||
// Ammo is not a container — slot 0 is `GetInventoryItemCount`'s own `PLAYER_AMMO_ID`
|
||
// leg, which never reaches the TYPEMASK fork.
|
||
contents_count: None,
|
||
quality,
|
||
name,
|
||
link,
|
||
// The ammo slot is a named deferral this slice (decision 0208 phase 1b): no click
|
||
// path wires it, so there's nothing for a pending op to lock and nowhere for it to
|
||
// fit — both stay the inert default.
|
||
locked: false,
|
||
equip_slots: Vec::new(),
|
||
// Ammo is a named deferral too: no drag path reaches the bar from this slot.
|
||
bar_placeable: false,
|
||
creator: None,
|
||
// Ammo carries no enchant slots to read: the wire never streams an ammo instance
|
||
// here, only the template id off the player descriptor — and for the same reason it
|
||
// carries no expiry countdown either (there is no instance guid to key one on).
|
||
enchants: Vec::new(),
|
||
duration_ms: None,
|
||
});
|
||
}
|
||
// Equipment 1..=19, then Bag0Slot..Bag3Slot (ids 20..23, PaperDollItemFrame.dbc-verified) —
|
||
// the bag bar's icon source: the equipped bag ITEM at INV_SLOT 19..22 (BAG_SLOT_FIRST../
|
||
// SLOT_BAG_FIRST in the container feed's own numbering). One band: the doll's live id is its
|
||
// wire slot plus one across the whole range.
|
||
for live in 1..=23u32 {
|
||
let Some(guid) = store.0.player_inv_slot(live as u8 - 1).filter(|g| *g != 0) else {
|
||
continue;
|
||
};
|
||
inv[live as usize] = slot_view(
|
||
objects,
|
||
items,
|
||
icons,
|
||
rolls,
|
||
commands,
|
||
pending,
|
||
names,
|
||
sub_classes,
|
||
has_relic_slot,
|
||
guid,
|
||
live,
|
||
);
|
||
}
|
||
inv
|
||
}
|
||
|
||
/// The six bank-bag slots' views (live ids 64..=69) — the same [`slot_view`] resolution as any
|
||
/// doll slot, off the descriptor's own `PLAYER_FIELD_BANK_BAG_SLOT_*` guids.
|
||
///
|
||
/// These are inventory slots, not bank-*window* state: the reference's bank paints and picks up a
|
||
/// bank bag through `GetInventoryItemTexture`/`PickupBagFromSlot` at
|
||
/// `BankButtonIDToInvSlotID(i, 1)` (BankFrame.lua:28-35, 194-198), and the guids stream in the
|
||
/// player descriptor whether or not a banker is open. So they ride the inventory feed, beside the
|
||
/// doll snapshot, rather than the `BankState` the bank window pushes.
|
||
fn bank_bag_slots(
|
||
store: &ObjectStore,
|
||
objects: &Objects,
|
||
items: &Items,
|
||
icons: Option<&ItemDisplays>,
|
||
rolls: crate::items::RollCatalogs,
|
||
commands: &NetCommands,
|
||
pending: &PendingItemOps,
|
||
names: &crate::names::NameCache,
|
||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||
) -> BankBagSlots {
|
||
let mut bags: BankBagSlots = Default::default();
|
||
for (i, bag) in bags.iter_mut().enumerate() {
|
||
let Some(guid) = store.0.player_bank_bag_slot(i as u8).filter(|g| *g != 0) else {
|
||
continue;
|
||
};
|
||
*bag = slot_view(
|
||
objects,
|
||
items,
|
||
icons,
|
||
rolls,
|
||
commands,
|
||
pending,
|
||
names,
|
||
sub_classes,
|
||
// A bank bag slot holds a BAG, and `find_equip_slot(INVTYPE_BAG)` answers the four
|
||
// bag slots whatever this says — the flag decides INVSLOT 17 alone, which no item in
|
||
// this band can reach. `false` here is provable, not a default.
|
||
false,
|
||
guid,
|
||
BANK_BAG_LIVE_FIRST + i as u32,
|
||
);
|
||
}
|
||
bags
|
||
}
|
||
|
||
/// `BankButtonIDToInvSlotID(1, isBag)` — the live-API id of the FIRST bank-bag slot. The band is
|
||
/// six wide (`BANK_BAG_SLOT_COUNT`).
|
||
const BANK_BAG_LIVE_FIRST: u32 = 64;
|
||
|
||
/// Fire the paper doll's per-group repaint events for one unit's snapshot transition, `arg1 =
|
||
/// token` — the [`crate::ui_unit::fire_transitions`] shape, for the combat-stats surface.
|
||
///
|
||
/// The groups ARE the ref's own event registrations (`PaperDollFrame.lua:14-28`, which
|
||
/// `PetPaperDollFrame.lua:12-20` repeats verbatim for the pet page), so each event fires exactly
|
||
/// when a value some handler of it reads has moved; `prev == None` (a first snapshot) counts as a
|
||
/// transition of every group, the `ui_unit` rule.
|
||
///
|
||
/// Shared by both feeds rather than transcribed twice (decision 1057): the pet page calls the same
|
||
/// `PaperDollFrame_Set*` helpers off the same events, so two copies of this grouping could only
|
||
/// ever drift into one page repainting on an edge the other misses.
|
||
pub(crate) fn fire_stat_transitions(
|
||
script: &mut UiScript,
|
||
token: &str,
|
||
prev: Option<&UnitCombatStats>,
|
||
stats: &UnitCombatStats,
|
||
) {
|
||
let tok = || ScriptValue::Str(token.to_string());
|
||
let changed = |sel: fn(&UnitCombatStats) -> Vec<i64>| prev.is_none_or(|p| sel(p) != sel(stats));
|
||
if changed(|s| {
|
||
let mut v: Vec<i64> = s.stats.iter().map(|&x| i64::from(x)).collect();
|
||
v.extend(
|
||
s.stat_pos
|
||
.iter()
|
||
.chain(s.stat_neg.iter())
|
||
.map(|&x| i64::from(x)),
|
||
);
|
||
v
|
||
}) {
|
||
script.fire_event("UNIT_STATS", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
s.resistances
|
||
.iter()
|
||
.chain(s.resistance_pos.iter())
|
||
.chain(s.resistance_neg.iter())
|
||
.map(|&x| i64::from(x))
|
||
.collect()
|
||
}) {
|
||
script.fire_event("UNIT_RESISTANCES", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
vec![
|
||
fixed(s.min_damage),
|
||
fixed(s.max_damage),
|
||
fixed(s.min_offhand_damage),
|
||
fixed(s.max_offhand_damage),
|
||
i64::from(s.physical_bonus_pos),
|
||
i64::from(s.physical_bonus_neg),
|
||
fixed(s.damage_percent),
|
||
]
|
||
}) {
|
||
script.fire_event("UNIT_DAMAGE", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
vec![
|
||
i64::from(s.main_attack_time_ms),
|
||
i64::from(s.offhand_attack_time_ms),
|
||
i64::from(s.has_offhand),
|
||
]
|
||
}) {
|
||
script.fire_event("UNIT_ATTACK_SPEED", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
vec![
|
||
i64::from(s.attack_power),
|
||
i64::from(s.attack_power_pos),
|
||
i64::from(s.attack_power_neg),
|
||
]
|
||
}) {
|
||
script.fire_event("UNIT_ATTACK_POWER", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
vec![
|
||
i64::from(s.ranged_attack_power),
|
||
i64::from(s.ranged_attack_power_pos),
|
||
i64::from(s.ranged_attack_power_neg),
|
||
i64::from(s.has_wand),
|
||
]
|
||
}) {
|
||
script.fire_event("UNIT_RANGED_ATTACK_POWER", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
vec![
|
||
fixed(s.ranged_min_damage),
|
||
fixed(s.ranged_max_damage),
|
||
i64::from(s.ranged_attack_time_ms),
|
||
]
|
||
}) {
|
||
script.fire_event("UNIT_RANGEDDAMAGE", vec![tok()]);
|
||
}
|
||
if changed(|s| {
|
||
vec![
|
||
i64::from(s.main_weapon_skill.0),
|
||
i64::from(s.main_weapon_skill.1),
|
||
i64::from(s.ranged_weapon_skill.0),
|
||
i64::from(s.ranged_weapon_skill.1),
|
||
]
|
||
}) {
|
||
script.fire_event("UNIT_ATTACK", vec![tok()]);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn feed_char(
|
||
// `ChrClasses.dbc` field 16, for the fit rule's relic half — `ui_items::find_equip_slot`
|
||
// (1803). Absent client data reads every class as an ordinary ranged wielder.
|
||
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
||
script: Option<NonSendMut<UiScript>>,
|
||
// The inventory read (2334): the self store, its change tick, the object lookup and the item
|
||
// entities' change watch.
|
||
mut inv: crate::items::Inventory,
|
||
items: Res<Items>,
|
||
icons: Option<Res<ItemDisplays>>,
|
||
// `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915).
|
||
enchants: Option<Res<crate::items::Enchants>>,
|
||
// `ItemRandomProperties` — the roll behind an equipped item's "of the Monkey" name (1547).
|
||
props: Option<Res<crate::items::RandomProperties>>,
|
||
commands: Res<NetCommands>,
|
||
mut feed: ResMut<CharFeedState>,
|
||
mut booth: ResMut<PaperDollBooth>,
|
||
pending: Res<PendingItemOps>,
|
||
names: Res<crate::names::NameCache>,
|
||
// `ItemSubClass.dbc` — `GetInventoryItemCount`'s bag gate (`slot_view`'s own note).
|
||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
// The pane owns the yaw and the booth mirrors it (0208 §5). Read off the widget's own
|
||
// `SetRotation` state since decision 1751 put the reference's `PaperDollFrame.xml` on the
|
||
// chain: the stock file declares a real `<PlayerModel name="CharacterModelFrame">` and drives
|
||
// it with `Model_OnLoad`/`Model_Rotate*`/`Model_OnUpdate`, where ours used to call a
|
||
// benilla-named `BenillaPaperDollModel_SetFacing` into a scalar on the VM.
|
||
// Write-if-different: an unconditional write would mark the booth changed every frame.
|
||
let yaw = script.model_pane_facing("CharacterModelFrame");
|
||
if booth.yaw != yaw {
|
||
booth.yaw = yaw;
|
||
}
|
||
|
||
// Resolved against THIS VM (1290/1291): a `/reload`'s replacement reads a fresh memo, so both
|
||
// snapshots below re-push and their events re-fire for it.
|
||
let (memo, vm_reset) = feed.vm.get_reset(&script);
|
||
|
||
let Some(store) = inv.self_store.iter().next() else {
|
||
// The self store's absence is NO DATA, never "the player has nothing equipped". The one
|
||
// window this branch runs with a previously-fed VM is the logout despawn frames —
|
||
// `SMSG_LOGOUT_COMPLETE` despawns the entity a full Update before `OnExit(InWorld)` runs
|
||
// the shutdown, and the `PLAYER_LOGOUT` handlers that follow still read equipment
|
||
// (`GetInventoryItemLink("player", …)` at logout is a stock addon save pattern; the
|
||
// reference keeps it valid through its shutdown). The clear this branch used to push was
|
||
// a pre-1290 relic: one VM then lived across logouts and the next character must not see
|
||
// this one's inventory — now the VM dies with the session and a fresh one starts empty
|
||
// ([`crate::ui_script::end_ui_session`]), so the clear only ever fired into a VM about to
|
||
// run its logout handlers. Same law as the container feed
|
||
// (`ui_items::feed::apply_container_source`).
|
||
return;
|
||
};
|
||
|
||
// `GetWeaponEnchantInfo`'s whole data path (the buff bar's TemporaryEnchantFrame row, plus 8
|
||
// corpus addons). Pushed EVERY frame and change-gated by nothing — including the 1439 gate
|
||
// below: the remaining time is a live countdown, so riding a snapshot diff would fire
|
||
// `UNIT_INVENTORY_CHANGED` on every tick. The reference recomputes it per call for exactly
|
||
// the same reason (`0x5d9d00`). Hoisted above the gate (it used to sit last): the push is
|
||
// order-free of the two snapshots, and a handler they fire reads the fresher value this way.
|
||
// Bound before the snapshot build below shadows `inv` with the pushed slot array.
|
||
let objects = &inv.objects;
|
||
script.set_weapon_enchants(
|
||
weapon_enchant(store, objects, EQUIPMENT_SLOT_MAINHAND),
|
||
weapon_enchant(store, objects, EQUIPMENT_SLOT_OFFHAND),
|
||
);
|
||
|
||
// The gate (1439), around the two snapshot builds only: the self descriptor (equipment
|
||
// slots, every stat field), the item stores behind the equipped templates and enchant
|
||
// names, the two catalogs, and the pending-op locks (held open while in flight; the
|
||
// resolving frame's own field update moves the object epoch).
|
||
let objects_moved = inv.changes.moved();
|
||
let templates_moved = memo.items_templates.moved(items.template_epoch());
|
||
let names_moved = memo.names_generation.moved(names.generation());
|
||
// The DISPLAY epoch, not a per-frame hold-open: the snapshot reads second-floored countdowns
|
||
// (`enchant_remaining_display_ms`), so it can only differ when this counter steps — once a
|
||
// second per ticking enchant, plus one final step at the elapse. Holding the gate open on
|
||
// `live_enchant_deadlines() > 0` (the first 1439 shape) rebuilt both snapshots and fired
|
||
// `UNIT_INVENTORY_CHANGED` at frame rate for the whole life of a poison.
|
||
let deadlines_moved = memo.enchant_deadlines.moved(inv.changes.countdown_steps());
|
||
let self_changed = !inv.self_changed.is_empty();
|
||
// `is_added` for the icon catalog: the feeds read only its load-once icon column;
|
||
// its model-cache half churns every frame (the containers gate's own note).
|
||
let icons_added = 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 = enchants.as_ref().is_some_and(|r| r.is_changed())
|
||
|| props.as_ref().is_some_and(|r| r.is_changed());
|
||
let pending_held = !pending.is_empty();
|
||
let pending_moved = memo.pending_epoch.moved(pending.epoch());
|
||
gate::trace(
|
||
"feed_char",
|
||
&[
|
||
("vm_reset", vm_reset),
|
||
("objects", objects_moved),
|
||
("templates", templates_moved),
|
||
("names", names_moved),
|
||
("deadlines", deadlines_moved),
|
||
("self", self_changed),
|
||
("icons", icons_added),
|
||
("enchants", enchants_changed),
|
||
("pending", pending_held),
|
||
("pending-epoch", pending_moved),
|
||
],
|
||
);
|
||
let gate = gate::Gate::new(
|
||
vm_reset
|
||
|| objects_moved
|
||
|| templates_moved
|
||
|| names_moved
|
||
|| deadlines_moved
|
||
|| self_changed
|
||
|| icons_added
|
||
|| enchants_changed
|
||
|| pending_held
|
||
|| pending_moved,
|
||
);
|
||
if gate.skip() {
|
||
return;
|
||
}
|
||
|
||
let stats = combat_stats(store, objects, &items, &commands);
|
||
if memo.last_stats.as_ref() != Some(&stats) {
|
||
gate.audit("feed_char", "the combat-stats snapshot");
|
||
// PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot
|
||
// must already be in the VM when they repaint (the ui_unit rule — a fire-first ordering
|
||
// paints the OLD values and, being transition-gated, never corrects itself).
|
||
let prev = memo.last_stats.take();
|
||
script.set_player_combat_stats(Some(stats.clone()));
|
||
fire_stat_transitions(&mut script, "player", prev.as_ref(), &stats);
|
||
memo.last_stats = Some(stats);
|
||
}
|
||
|
||
let inv = inventory_slots(
|
||
store,
|
||
objects,
|
||
&items,
|
||
icons.as_deref(),
|
||
crate::items::RollCatalogs {
|
||
enchants: enchants.as_deref(),
|
||
props: props.as_deref(),
|
||
},
|
||
&commands,
|
||
&pending,
|
||
&names,
|
||
sub_classes.as_deref(),
|
||
classes.as_deref().map(|t| &t.0),
|
||
);
|
||
let bank_bags = bank_bag_slots(
|
||
store,
|
||
objects,
|
||
&items,
|
||
icons.as_deref(),
|
||
crate::items::RollCatalogs {
|
||
enchants: enchants.as_deref(),
|
||
props: props.as_deref(),
|
||
},
|
||
&commands,
|
||
&pending,
|
||
&names,
|
||
sub_classes.as_deref(),
|
||
);
|
||
// One transition for both bands: they are the same descriptor read at two offsets, and
|
||
// `UNIT_INVENTORY_CHANGED` is the one repaint signal either has. Pushing them separately
|
||
// would fire it twice for a single wire update.
|
||
// `PLAYERBANKSLOTS_CHANGED`'s two producers, planned off the OLD memo before the push
|
||
// replaces it. `false` = P1, the player-descriptor path (`0x5ddd6e`, no arguments — a bag
|
||
// arriving, leaving or exchanged); `true` = P2, the item-object path (`0x4c728d`,
|
||
// `arg1 = "player"` — the same bag, its own fields changed). The full carve and why the guid
|
||
// is the only sound discriminator are at `ui_items::feed`'s vault twin; decision 2140.
|
||
let bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT] =
|
||
std::array::from_fn(|i| store.0.player_bank_bag_slot(i as u8).unwrap_or(0));
|
||
let repainted: Vec<bool> = (0..BANK_BAG_SLOT_COUNT)
|
||
.filter_map(|i| {
|
||
if bank_bag_guids[i] != memo.last_bank_bag_guids[i] {
|
||
Some(false)
|
||
} else {
|
||
let was = memo.last_bank_bags.as_ref().and_then(|b| b[i].as_ref());
|
||
(!same_item(was, bank_bags[i].as_ref())).then_some(true)
|
||
}
|
||
})
|
||
.collect();
|
||
memo.last_bank_bag_guids = bank_bag_guids;
|
||
if memo.last_inv.as_ref() != Some(&inv) || memo.last_bank_bags.as_ref() != Some(&bank_bags) {
|
||
gate.audit("feed_char", "the inventory snapshot");
|
||
script.set_inventory_slots(inv.clone());
|
||
script.set_bank_bag_slots(bank_bags.clone());
|
||
script.fire_event(
|
||
"UNIT_INVENTORY_CHANGED",
|
||
vec![ScriptValue::Str("player".to_string())],
|
||
);
|
||
// The six bank BAG buttons' only repaint signal (1771). `BankItemButtonTemplate`'s
|
||
// `<OnUpdate>` is `CursorOnUpdate()` — despite its name, `BankFrameItemButton_OnUpdate`
|
||
// (the icon/count/lock repaint) is reached ONLY from `BankFrameItemButton_OnEvent`, and
|
||
// only for `PLAYERBANKSLOTS_CHANGED` and `BANKFRAME_OPENED`. Without this the buttons
|
||
// showed whatever was true at the last unrelated vault-slot event: a bag dropped in never
|
||
// appeared, one moved between slots stayed drawn in its old one, and a lock left over from
|
||
// the drop stood greyed until the window was reopened.
|
||
//
|
||
// The VAULT band's copy of this event is `ui_items::feed_containers`' — each feed
|
||
// announces the band whose data it owns, and `feed_char` is ordered first, so both fire
|
||
// after their own push.
|
||
//
|
||
// **Which arguments it carries depends on WHICH producer fired** (CARVED — wow-re
|
||
// `system/object-layer/scratch/bank-slot-event-law.md` §3/§4, folded back in 2140). The
|
||
// descriptor watcher `0x5ddd6e` calls `FrameScript_SignalEvent 0x703e50`, an
|
||
// `__fastcall(ecx = id)` with a plain `ret` and no vararg push — zero Lua values; the
|
||
// item-object notifier `0x4c728d` goes through `SignalEvent2` and pushes the literal
|
||
// string `"player"`. benilla fired the argless one for both until 2140. An event is fired
|
||
// per changed slot, as the watcher does — the slot travels in *how many* times it fires,
|
||
// never in an argument (1776).
|
||
memo.last_inv = Some(inv);
|
||
memo.last_bank_bags = Some(bank_bags);
|
||
}
|
||
// Outside the push block, because the identical-bag swap it exists for moves no view at all.
|
||
if !repainted.is_empty() {
|
||
gate.audit("feed_char", "a bank bag slot transition");
|
||
}
|
||
for same_bag in repainted {
|
||
let args = if same_bag {
|
||
vec![ScriptValue::Str("player".to_string())]
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
script.fire_event("PLAYERBANKSLOTS_CHANGED", args);
|
||
}
|
||
}
|
||
|
||
/// Whether two bank-bag views hold the same ITEM — everything but `locked`, which is
|
||
/// `ITEM_LOCK_CHANGED`'s business and must not masquerade as a slot change.
|
||
fn same_item(a: Option<&InvSlotView>, b: Option<&InvSlotView>) -> bool {
|
||
match (a, b) {
|
||
(None, None) => true,
|
||
(Some(a), Some(b)) => {
|
||
let unlocked = |v: &InvSlotView| InvSlotView {
|
||
locked: false,
|
||
..v.clone()
|
||
};
|
||
unlocked(a) == unlocked(b)
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// The client's own 0-based `EQUIPMENT_SLOT_*` ids for the two weapon slots — the numbers
|
||
/// `GetWeaponEnchantInfo 0x4c9790` hands its container lookup (`push 0xf` at `0x4c97c3`,
|
||
/// `push 0x10` at `0x4c988b`). The live-API `GetInventorySlotInfo` ids the *frames* use are one
|
||
/// higher (16 `MainHandSlot`, 17 `SecondaryHandSlot`).
|
||
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
|
||
const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
|
||
|
||
/// `ITEM_FIELD_ENCHANTMENT`'s temporary slot (0 = permanent, 1 = temporary, 2..6 random property)
|
||
/// — the slot index the reference passes to its remaining-time reader (`push 0x1`, `0x4c9828`).
|
||
const TEMP_ENCHANTMENT_SLOT: u8 = 1;
|
||
|
||
/// One weapon's temporary enchantment for [`benilla_ui::script::WeaponEnchant`], read off the
|
||
/// equipped item object.
|
||
///
|
||
/// **The RAW enchantment triple, not [`InvSlotView::enchants`]**: that view is tooltip-shaped and
|
||
/// drops both an id the `SpellItemEnchantment` catalog cannot name and the whole `Flags & 0x2`
|
||
/// print-no-line family (decision 0928 — the totem weapon imbues), which is exactly the set the
|
||
/// enchant row exists to show. The binary's gate is `[descriptor+0x4c] != 0`, the id and nothing
|
||
/// else, and `item_enchant` already answers `None` for a zero id.
|
||
fn weapon_enchant(
|
||
store: &ObjectStore,
|
||
objects: &Objects,
|
||
slot0: u8,
|
||
) -> Option<benilla_ui::script::WeaponEnchant> {
|
||
let guid = store.0.player_inv_slot(slot0)?;
|
||
// The *deadline* read, not the tooltip's: an elapsed timer must answer 0, not "no timer".
|
||
let remaining_ms = objects
|
||
.countdowns(guid)
|
||
.and_then(|c| c.enchant_deadline_ms(u32::from(TEMP_ENCHANTMENT_SLOT)));
|
||
let obj = objects.object(guid)?;
|
||
obj.item_enchant(TEMP_ENCHANTMENT_SLOT)?;
|
||
Some(benilla_ui::script::WeaponEnchant {
|
||
remaining_ms,
|
||
charges: obj.item_enchant_charges(TEMP_ENCHANTMENT_SLOT),
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use benilla_protocol::messages::ObjectFields;
|
||
use bevy::ecs::system::RunSystemOnce;
|
||
|
||
/// **The skill pair splits `(value + PERM, TEMP)`, not `(value, temp + perm)`** — the reader
|
||
/// every skill-shaped binding goes through (`0x5ea460`, VERIFIED: wow-re
|
||
/// `ui/scratch/unitrangedattack-skill-pair.md` §3).
|
||
///
|
||
/// It reported the right TOTAL and the wrong halves until decision 1812, which is exactly why
|
||
/// nothing caught it: `UnitAttackBothHands`'s caller prints the base and colours `base + mod`,
|
||
/// so both shapes agree on the number a player adds up and disagree on the two they read. The
|
||
/// fixture below is the one the record quotes — 300 / +5 talent / +10 aura — where the
|
||
/// reference shows **305** with a green **+10** and ours showed 300 with a green +15.
|
||
///
|
||
/// The zero-value leg is the other half of the law (`0x5ea4b4 jle`): an unknown line must stay
|
||
/// a flat 0 rather than reading back a bare talent bonus.
|
||
#[test]
|
||
fn a_skill_pair_folds_the_permanent_bonus_into_the_base() {
|
||
/// `PLAYER_SKILL_INFO_1_1` — id|step, then value|max, then temp|perm, per slot.
|
||
const F_SKILL: u16 = 718;
|
||
let pair = |lo: u16, hi: u16| u32::from(lo) | (u32::from(hi) << 16);
|
||
// Slot 0: Defense (95) at 300, +10 temporary, +5 permanent.
|
||
// Slot 1: Swords (43) at 0 — known id, no value, a +7 permanent that must NOT surface.
|
||
let store = ObjectStore(ObjectFields::from_pairs(&[
|
||
(F_SKILL, pair(95, 0)),
|
||
(F_SKILL + 1, pair(300, 300)),
|
||
(F_SKILL + 2, pair(10, 5)),
|
||
(F_SKILL + 3, pair(43, 0)),
|
||
(F_SKILL + 4, pair(0, 300)),
|
||
(F_SKILL + 5, pair(0, 7)),
|
||
]));
|
||
assert_eq!(
|
||
skill_pair(&store, 95),
|
||
(305, 10),
|
||
"the permanent bonus belongs to the base and the temporary one is the modifier"
|
||
);
|
||
assert_eq!(
|
||
skill_pair(&store, 43),
|
||
(0, 0),
|
||
"a line at value 0 skips the perm add — a bare talent bonus is not a skill"
|
||
);
|
||
assert_eq!(
|
||
skill_pair(&store, 162),
|
||
(0, 0),
|
||
"a line the player does not have at all"
|
||
);
|
||
// A malus deeper than the skill is representable, which is why the pair is signed.
|
||
let cursed = ObjectStore(ObjectFields::from_pairs(&[
|
||
(F_SKILL, pair(95, 0)),
|
||
(F_SKILL + 1, pair(5, 300)),
|
||
(F_SKILL + 2, pair(0, u16::MAX - 9)), // perm = -10
|
||
]));
|
||
assert_eq!(skill_pair(&cursed, 95), (-5, 0));
|
||
}
|
||
|
||
/// `PLAYER_FIELD_BANK_BAG_SLOT_1` — bank bag slot 0's guid pair (protocol test `bank.rs`).
|
||
const F_BANK_BAG_1: u16 = 612;
|
||
/// `OBJECT_FIELD_ENTRY` on the item object.
|
||
const F_OBJECT_ENTRY: u16 = 3;
|
||
/// `ITEM_FIELD_STACK_COUNT` — one of the six item fields the reference's own watcher covers.
|
||
const F_ITEM_STACK_COUNT: u16 = 14;
|
||
/// "Traveler's Backpack" — any container entry; the feed only needs it to resolve.
|
||
const BAG_ENTRY: u32 = 4500;
|
||
const BAG: u64 = 0x4000_0000_0000_0abc;
|
||
|
||
/// Re-stream one live item object's fields — the wire's values delta, which is what moves
|
||
/// the item entity's own change tick (2334); the create-time spawn is
|
||
/// [`crate::items::test_spawn_item`].
|
||
fn restream(app: &mut App, guid: u64, fields: ObjectFields) {
|
||
let entity = app
|
||
.world()
|
||
.resource::<crate::net::GuidIndex>()
|
||
.0
|
||
.get(&guid)
|
||
.copied()
|
||
.expect("the item was streamed");
|
||
*app.world_mut().get_mut::<ObjectStore>(entity).unwrap() = ObjectStore(fields);
|
||
}
|
||
|
||
/// A world with the player carrying `bag` (or nothing) in the FIRST bank bag slot, and a
|
||
/// recorder frame registered for the two events the reference's bank bag buttons repaint on.
|
||
fn world_with_bank_bag(bag: Option<u64>) -> App {
|
||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||
let mut app = App::new();
|
||
app.init_resource::<CharFeedState>()
|
||
.init_resource::<PaperDollBooth>()
|
||
.init_resource::<PendingItemOps>()
|
||
.init_resource::<Items>()
|
||
.init_resource::<crate::net::GuidIndex>()
|
||
.init_resource::<crate::names::NameCache>()
|
||
.insert_resource(NetCommands(tx));
|
||
let fields = match bag {
|
||
Some(g) => ObjectFields::from_pairs(&[
|
||
(F_BANK_BAG_1, g as u32),
|
||
(F_BANK_BAG_1 + 1, (g >> 32) as u32),
|
||
]),
|
||
None => ObjectFields::from_pairs(&[(F_BANK_BAG_1, 0), (F_BANK_BAG_1 + 1, 0)]),
|
||
};
|
||
app.world_mut().spawn((SelfPlayer, ObjectStore(fields)));
|
||
if let Some(g) = bag {
|
||
// The bag is an object in the one index (2334), its template still on `Items`.
|
||
crate::items::test_spawn_item(
|
||
app.world_mut(),
|
||
g,
|
||
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]),
|
||
true,
|
||
);
|
||
app.world_mut().resource_mut::<Items>().insert_template(
|
||
BAG_ENTRY,
|
||
Some(crate::items::test_template("Traveler's Backpack")),
|
||
);
|
||
}
|
||
let script = UiScript::new().unwrap();
|
||
script
|
||
.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("PLAYERBANKSLOTS_CHANGED")
|
||
f:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
||
f:SetScript("OnEvent", function()
|
||
table.insert(SEEN, event .. " " .. tostring(arg1))
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
app.insert_non_send_resource(script);
|
||
app
|
||
}
|
||
|
||
fn seen(app: &mut App) -> Vec<String> {
|
||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||
let out = script.eval::<Vec<String>>("return SEEN").unwrap();
|
||
script.run("SEEN = {}").unwrap();
|
||
out
|
||
}
|
||
|
||
/// **A bank bag slot's only repaint signal** (1771). `PLAYERBANKSLOTS_CHANGED` is the one
|
||
/// event that reaches `BankFrameItemButton_OnUpdate` for the six bag buttons —
|
||
/// `UNIT_INVENTORY_CHANGED`, which this feed already fired, is not registered by them at all,
|
||
/// and `ITEM_LOCK_CHANGED` repaints the desaturation without ever touching the icon. Without
|
||
/// this fire a bag dropped into the bank never appeared, and one moved between slots stayed
|
||
/// drawn where it had been.
|
||
#[test]
|
||
fn a_bank_bag_slot_filling_fires_playerbankslots_changed() {
|
||
let mut app = world_with_bank_bag(Some(BAG));
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let events = seen(&mut app);
|
||
assert!(
|
||
events.contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string()),
|
||
"the repaint event, and it carries NO argument — `0x703e50` pushes none, and the \
|
||
recorder stringifies the absent arg1 as nil. Got {events:?}"
|
||
);
|
||
assert!(
|
||
events.contains(&"UNIT_INVENTORY_CHANGED player".to_string()),
|
||
"and the doll's, unchanged, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// **The second producer** (decision 2140). The same bag, its own `ITEM_FIELD_STACK_COUNT`
|
||
/// moving, is not the descriptor path — it is `0x4c7180`'s item-object path, which goes
|
||
/// through `SignalEvent2` and pushes the unit token `"player"`. benilla fired the argless
|
||
/// descriptor shape for both.
|
||
#[test]
|
||
fn a_bank_bags_own_field_moving_fires_the_item_object_producer() {
|
||
let mut app = world_with_bank_bag(Some(BAG));
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
assert!(seen(&mut app).contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string()));
|
||
|
||
// The SAME bag guid, restacked. Only the item object moved — a values update on the
|
||
// live item entity, which is what the wire's own delta is (2334).
|
||
restream(
|
||
&mut app,
|
||
BAG,
|
||
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY), (F_ITEM_STACK_COUNT, 3)]),
|
||
);
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let events = seen(&mut app);
|
||
assert!(
|
||
events.contains(&"PLAYERBANKSLOTS_CHANGED player".to_string()),
|
||
"the item path pushes the token, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// Two IDENTICAL bags exchanged between two bank bag slots — the case the pushed view cannot
|
||
/// see, because two instances of one bag model push the same `InvSlotView`. The reference
|
||
/// watches the descriptor GUIDs, so it fires the argless event twice; benilla, diffing the
|
||
/// view, fired nothing at all. Same shape as 1777's `BAG_CLOSED`, one band over.
|
||
#[test]
|
||
fn two_identical_bags_swapped_between_bank_bag_slots_still_announce() {
|
||
let mut app = world_with_bank_bag(Some(BAG));
|
||
// A second, byte-identical bag in bank bag slot 2.
|
||
const BAG2: u64 = 0x4000_0000_0000_0abd;
|
||
// The two bank bag slots' guids, rewritten wholesale — `ObjectFields` has no setter, and
|
||
// a fresh store is what a descriptor update produces anyway.
|
||
let set_slots = |app: &mut App, a: u64, b: u64| {
|
||
let mut q = app
|
||
.world_mut()
|
||
.query_filtered::<&mut ObjectStore, With<SelfPlayer>>();
|
||
let mut store = q.single_mut(app.world_mut()).unwrap();
|
||
*store = ObjectStore(ObjectFields::from_pairs(&[
|
||
(F_BANK_BAG_1, a as u32),
|
||
(F_BANK_BAG_1 + 1, (a >> 32) as u32),
|
||
(F_BANK_BAG_1 + 2, b as u32),
|
||
(F_BANK_BAG_1 + 3, (b >> 32) as u32),
|
||
]));
|
||
};
|
||
set_slots(&mut app, BAG, BAG2);
|
||
crate::items::test_spawn_item(
|
||
app.world_mut(),
|
||
BAG2,
|
||
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]),
|
||
true,
|
||
);
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let _ = seen(&mut app);
|
||
|
||
// Exchange them. Every pushed view is equal before and after.
|
||
set_slots(&mut app, BAG2, BAG);
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let events = seen(&mut app);
|
||
assert_eq!(
|
||
events
|
||
.iter()
|
||
.filter(|e| *e == "PLAYERBANKSLOTS_CHANGED nil")
|
||
.count(),
|
||
2,
|
||
"one argless event per slot whose guid moved, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// The other half of the same rule: a LOCK flip is not a slot change. The reference has a
|
||
/// separate event for it (`ITEM_LOCK_CHANGED`, fired by `ui_items::feed_containers` off
|
||
/// `LockTransitions`), and firing the repaint event for it too would announce a change to the
|
||
/// item that did not happen.
|
||
#[test]
|
||
fn a_lock_flip_alone_does_not_fire_the_repaint_event() {
|
||
let mut app = world_with_bank_bag(Some(BAG));
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let _ = seen(&mut app);
|
||
|
||
// The same bag, now locked by an outbound move.
|
||
app.world_mut().resource_mut::<PendingItemOps>().add([(
|
||
EQUIPMENT_BAG,
|
||
BANK_BAG_LIVE_FIRST,
|
||
BAG,
|
||
1,
|
||
)]);
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let events = seen(&mut app);
|
||
assert!(
|
||
events.contains(&"UNIT_INVENTORY_CHANGED player".to_string()),
|
||
"the snapshot did move — `locked` is part of it, got {events:?}"
|
||
);
|
||
assert!(
|
||
!events
|
||
.iter()
|
||
.any(|e| e.starts_with("PLAYERBANKSLOTS_CHANGED")),
|
||
"…but the ITEM did not change, so the repaint event stays quiet, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// The gate hole behind the stuck grey: the frame an op's lock CLEARS, `!pending.is_empty()`
|
||
/// reads false and every "is anything in flight?" input closes — so a feed that had pushed
|
||
/// `locked: true` must have some other reason to run, or the stale lock stands forever.
|
||
/// [`PendingItemOps::epoch`] is that reason.
|
||
#[test]
|
||
fn the_frame_a_lock_clears_still_repushes_the_slot() {
|
||
let mut app = world_with_bank_bag(Some(BAG));
|
||
app.world_mut().resource_mut::<PendingItemOps>().add([(
|
||
EQUIPMENT_BAG,
|
||
BANK_BAG_LIVE_FIRST,
|
||
BAG,
|
||
1,
|
||
)]);
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
let _ = seen(&mut app);
|
||
assert!(
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<bool>(r#"return IsInventoryItemLocked(64) and true or false"#)
|
||
.unwrap(),
|
||
"in flight, the slot reads locked"
|
||
);
|
||
|
||
// The server answers with a refusal: the lock clears without any object moving, which is
|
||
// exactly the case `!is_empty()` cannot see.
|
||
let cleared = app
|
||
.world_mut()
|
||
.resource_mut::<PendingItemOps>()
|
||
.clear_by_failure(BAG);
|
||
assert_eq!(cleared, vec![(EQUIPMENT_BAG, BANK_BAG_LIVE_FIRST)]);
|
||
app.world_mut().run_system_once(feed_char).unwrap();
|
||
assert!(
|
||
!app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<bool>(r#"return IsInventoryItemLocked(64) and true or false"#)
|
||
.unwrap(),
|
||
"the unlock has to reach the VM in the same frame it happened"
|
||
);
|
||
}
|
||
}
|