Files
benilla-octo/crates/benilla-app/src/ui_char.rs
T
SamandClaude Fable 5 5360a44f89 event-driven UI feeds, and the character window's cost lets go
- the eight heaviest UI feeds — spellbook, party, character, bags, talents, units, pet, player — rebuild only when an input moved instead of every frame: 216 → 12 µs a frame parked
- the character window's cost settles once it closes: enchant countdowns feed by the second instead of firing an inventory event into all 20 slots every frame, the paper-doll booth parks with its camera, and a window hidden under the cursor fires OnLeave so its tooltip goes with it — the reference's hover-hide law
- the walk stops paying for finished work: the loading-screen tally's per-placement walk latches off once the world is ready, the settled placement spawner is one integer compare, and billboards stop dirtying every card's transform
- never-rendering hierarchy nodes — rig anchors, joints, doodad hosts — keep the hide chain but leave the per-camera visibility sweep: 25,198 → 18,026 swept entities at Goldshire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:01:11 +07:00

1028 lines
47 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 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 pane's `Model:SetRotation` transcription writes the VM-side
//! value ([`UiScript::paperdoll_yaw`]); the feed mirrors it onto [`PaperDollBooth`] so the booth
//! re-bakes at the new facing (decision 0208 §5).
//!
//! 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, InvSlotView, InventorySlots, ScriptValue, SkillEntry, SkillsState,
UiScript, UnitCombatStats, EQUIPMENT_BAG, SKILL_DEFENSE, SKILL_UNARMED,
};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, ObjectStore, 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, 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)]
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>,
/// 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).
items_objects: gate::Watch,
items_templates: gate::Watch,
names_generation: 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,
}
/// 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)
.before(UiInput),
watch_skill_ups.before(UiInput),
feed_skills.before(UiInput),
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).
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: the ERR_SKILL_UP_SI line (GlobalStrings.lua:1838).
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})");
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::Skill,
format!("Your skill in {name} has increased to {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})");
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::Skill,
format!("You have gained the {name} skill."),
));
} 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<&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 (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else {
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, items: &Items, slot0: u8) -> Option<u32> {
let guid = store.0.player_inv_slot(slot0)?;
items.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,
items: &mut Items,
commands: &NetCommands,
slot0: u8,
) -> u32 {
let Some(entry) = slot_entry(store, items, 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, temp+perm bonus)` pair from the `PLAYER_SKILL_INFO` triplets;
/// `(0, 0)` when the line isn't known (a ranged pair with nothing equipped).
fn skill_pair(store: &ObjectStore, skill_id: u32) -> (u32, 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 {
return (
u32::from(s.value),
i32::from(s.temp_bonus) + i32::from(s.perm_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),
ranged_weapon_skill: (0, 0),
defense_skill: (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, items: &mut 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, items, 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, items, 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, items, commands, SLOT_MAIN_HAND);
let ranged_skill_id = slot_entry(store, items, 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),
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),
..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`). `slot0` is
/// the wire `EQUIPMENT_SLOT_*`/`INV_SLOT` index (0..22 — equipment 0..18, the four equipped-bag
/// icons 19..22); the live-API id `slot_view` reports through is `slot0 + 1` either way.
#[allow(clippy::too_many_arguments)] // the slot resolve's full read set — the bag feed's twin
fn slot_view(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
enchant_rows: Option<&crate::items::Enchants>,
commands: &NetCommands,
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
slot0: u8,
) -> Option<InvSlotView> {
let guid = store.0.player_inv_slot(slot0)?;
// The temp-enchant countdowns, read before the object borrow (both live on `Items`) — the bag
// feed's twin.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
let obj = items.object(guid)?;
let entry = obj.object_entry()?;
let count = obj.item_stack_count().unwrap_or(1).max(1);
// A worn ammo container's contents, collected while the bag object is borrowed — applied
// below once the template's class says quiver.
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);
// 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)],
)
}),
enchant_rows,
);
let t = items.template(entry, guid, commands);
let (name, quality, display, link, equip_slots, class, bar_placeable) = match t {
Some(t) => (
Some(t.name.clone()),
t.quality as i32,
t.display_info_id,
Some(item_link(entry, &t.name, t.quality)),
find_equip_slot(t.inventory_type),
t.class,
t.placeable_on_action_bar(),
),
None => (None, 0, 0, None, Vec::new(), 0, false),
};
// The quiver's bag-bar count is what's INSIDE it: the ref's `GetInventoryItemCount("player",
// bagSlot)` returns the ammo left in a worn ammo container — the "162" on the bag bar — and
// the item's own stack (1) for any other bag, which the count text then hides. Gated on
// ITEM_CLASS_QUIVER 11 (INFERRED from the ref UI's observable behavior — regular bags never
// show a contents sum; named in the decision record).
let count = if class == 11 {
content_guids
.iter()
.filter_map(|&g| items.object(g))
.map(|o| o.item_stack_count().unwrap_or(1).max(1))
.sum()
} else {
count
};
let icon = icons
.and_then(|i| i.catalog.get(display))
.and_then(|d| d.icon.clone());
let live_id = u32::from(slot0) + 1;
Some(InvSlotView {
item_id: entry,
icon,
count,
quality,
name,
link,
durability,
flags,
locked: pending.contains(EQUIPMENT_BAG, live_id),
equip_slots,
bar_placeable,
creator,
enchants,
})
}
/// 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, items: &Items, ammo_id: u32) -> u32 {
let mut n = 0;
let mut add = |guid: Option<u64>| {
if let Some(o) = guid.and_then(|g| items.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| items.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| items.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,
items: &mut Items,
icons: Option<&ItemDisplays>,
enchant_rows: Option<&crate::items::Enchants>,
commands: &NetCommands,
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
) -> InventorySlots {
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,
item_id: ammo_id,
icon,
count: ammo_count(store, items, ammo_id),
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.
enchants: Vec::new(),
});
}
for slot in 1..=19u8 {
inv[usize::from(slot)] = slot_view(
store,
items,
icons,
enchant_rows,
commands,
pending,
names,
slot - 1,
);
}
// 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), same `slot_view` resolution as any equipment slot.
for (i, slot) in (20u8..=23).enumerate() {
inv[usize::from(slot)] = slot_view(
store,
items,
icons,
enchant_rows,
commands,
pending,
names,
19 + i as u8,
);
}
inv
}
/// 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()]);
}
}
#[allow(clippy::too_many_arguments)]
fn feed_char(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915).
enchants: Option<Res<crate::items::Enchants>>,
commands: Res<NetCommands>,
mut feed: ResMut<CharFeedState>,
mut booth: ResMut<PaperDollBooth>,
pending: Res<PendingItemOps>,
mut names: ResMut<crate::names::NameCache>,
) {
let Some(mut script) = script else {
return;
};
// The pane's Model:SetRotation transcription owns the yaw; the booth mirrors it (0208 §5).
// Write-if-different: an unconditional write would mark the booth changed every frame.
let yaw = script.paperdoll_yaw();
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) = self_q.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.
script.set_weapon_enchants(
weapon_enchant(store, &items, EQUIPMENT_SLOT_MAINHAND),
weapon_enchant(store, &items, 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 = memo.items_objects.moved(items.object_epoch());
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(items.enchant_display_epoch());
let self_changed = !changed_self.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());
let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed());
let pending_held = !pending.is_empty();
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),
],
);
let gate = gate::Gate::new(
vm_reset
|| objects_moved
|| templates_moved
|| names_moved
|| deadlines_moved
|| self_changed
|| icons_added
|| enchants_changed
|| pending_held,
);
if gate.skip() {
return;
}
let stats = combat_stats(store, &mut 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,
&mut items,
icons.as_deref(),
enchants.as_deref(),
&commands,
&pending,
&mut names,
);
if memo.last_inv.as_ref() != Some(&inv) {
gate.audit("feed_char", "the inventory snapshot");
script.set_inventory_slots(inv.clone());
script.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![ScriptValue::Str("player".to_string())],
);
memo.last_inv = Some(inv);
}
}
/// 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,
items: &Items,
slot0: u8,
) -> Option<benilla_ui::script::WeaponEnchant> {
let guid = store.0.player_inv_slot(slot0)?;
// Before the object borrow — the deadlines and the objects both live on `Items`. The
// *deadline* read, not the tooltip's: an elapsed timer must answer 0, not "no timer".
let remaining_ms = items.enchant_deadline_ms(guid, u32::from(TEMP_ENCHANTMENT_SLOT));
let obj = items.object(guid)?;
obj.item_enchant(TEMP_ENCHANTMENT_SLOT)?;
Some(benilla_ui::script::WeaponEnchant {
remaining_ms,
charges: obj.item_enchant_charges(TEMP_ENCHANTMENT_SLOT),
})
}