Files
benilla-octo/crates/benilla-app/src/ui_inspect.rs
T
SamandClaude Fable 5.1 eb554c0957 the inspect and talent windows are the reference's own
- the inspect and talent windows are the reference's own: `Blizzard_InspectUI` and `Blizzard_TalentUI` are LoadOnDemand addons whose real `.xml`/`.lua` live inside `patch.MPQ` — the loose `Interface\AddOns` folder holds only a `.pub` signature, which three notes in this tree had read as "packed, no source to migrate from", one wrong inference that kept five windows on hand-written transcriptions; 2300 lines of ours out, no engine work needed, and three things our talent transcription had quietly invented fell out (there was no level-10 gate, every tree scrolled where a shallow one does not, and a nil `TalentMicroButton` threw out of OnShow before the window painted)
- a nil unit token RAISES in 53 of 83 bindings: wow-re censused the registrar table at `0x850438` — `UnitName(nil)` raises and `UnitIsVisible(nil)` is quiet, one line apart in stock files, so there is no rule to derive, the table is the rule; ours answered a default and our own test had pinned it; both token positions of the seven two-token predicates are gated, `GetRaidTargetIndex` too, and `UnitIsAFK`/`UnitIsDND` are confirmed not to exist in 5875 at all (zero occurrences under a positive control) — we invented them, and they stay deliberately ungated
- `EditBox:SetNumber` did not exist (the only chain file calling it is `MoneyInputFrame.lua`, on the chain since the money kit): it is byte-identical to `SetText`, formats through `%.14g` in MSVC's CRT (`1e+020`, `1.#INF`), and `OnTextChanged` never fires from an edit — it fires from the dirty-word drain inside the box's own OnUpdate, coalesced to one per box per frame, where ours fired synchronously and `MoneyInputFrame_SetCopper` raised on it; `PanelTemplates_TabResize`'s `tab` argument is optional as the reference's is (stock macro tabs call it with `0`)
- the skull or star on your CURRENT target never appeared or cleared until you re-targeted — `RAID_TARGET_UPDATE` was never fired, and stock `TargetFrame.lua` hangs its repaint off it; a raid-to-party conversion now empties the mark board as `0x4ba550` does; and the group-loot roll popups were already on the chain inside stock `LootFrame.xml` — our 546-line `GroupLootFrame.xml` had been shadowing all four of them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:06:18 +07:00

268 lines
12 KiB
Rust

//! The app-side **inspect feed** (decision 0631) — the bridge that turns another player's PUBLIC
//! descriptor into the slot views the inspect window's Lua reads.
//!
//! The `CanInspect`/`CheckInteractDistance` distance map used to be fed from here too, back when it
//! held popup tokens only and only for players. It is unit-general now (B304) and lives with the
//! other unit-token feeds — `crate::ui_unit::feed_unit_reach`.
//!
//! This is [`crate::ui_char`]'s pattern turned onto a *foreign* unit, and the difference between
//! the two is the whole reason this module exists rather than a flag on that one:
//!
//! | | the character window | the inspect window |
//! |---|---|---|
//! | source | `PLAYER_FIELD_INV_SLOT_*` guids (PRIVATE) | `PLAYER_VISIBLE_ITEM_*` entries (PUBLIC) |
//! | resolves via | item **objects** → templates | templates only |
//! | so it can show | counts, durability, locks, creator | icon, name, quality |
//!
//! Other players' inventory guids are server-private, so there are no item objects to read — the
//! visible-item entry is the only path (`entities::equipment`'s header records the same finding for
//! rendering their gear). That is exactly why the reference's inspect paper doll shows no stack
//! counts and no durability: the client has none either.
//!
//! Three jobs, each frame, before the VM ticks:
//!
//! - **Drain the intents.** `NotifyInspect(unit)` → resolve the token to a player guid → send
//! `CMSG_INSPECT` and latch the token as the inspect target. `ClearInspectPlayer()` → drop it.
//! The window does not wait for `SMSG_INSPECT` (it carries only the echoed guid, and the ref
//! paints immediately) — the request exists because server-side it also sets our selection.
//! - **Resolve the view.** While a target is latched: re-resolve the **token** each frame (so the
//! window follows a re-target exactly as the ref's does, `InspectFrame_OnEvent`'s
//! `PLAYER_TARGET_CHANGED` arm), then read the 19 visible-item entries → the ask-once item
//! template cache → icon/name/quality/link, and push it on change. A slot whose template answer
//! is still in flight carries its `item_id` with `icon: None` and fills on a later frame, the
//! `ui_char` rule.
//! - **Point the booth.** The `"inspect"` body booth ([`InspectBooth`]) gets the resolved entity
//! and the pane's yaw, so it bakes the inspected player's dressed look (decision 0631 §4).
//!
//! Events, fired on transitions for the **inspected token** (the ref's own registration set,
//! `InspectPaperDollFrame.lua:2-5` + `l.82`): `UNIT_INVENTORY_CHANGED` when any slot view changes,
//! and `UNIT_LEVEL` when the unit's level does. A first resolve counts as a transition (the
//! `ui_unit` rule).
use bevy::prelude::*;
use benilla_ui::script::{InspectView, InvSlotView, InventorySlots, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
use crate::portrait::InspectBooth;
use crate::ui_script::UiInput;
/// Which unit the inspect window is bound to — the ref's `InspectFrame.unit`, mirrored app-side so
/// the feed knows whether to resolve anything. The **token** is the identity (not the guid): the
/// reference re-reads the token on every retarget, so `"target"` follows the selection.
#[derive(Resource, Default)]
pub(crate) struct InspectTarget {
pub(crate) token: Option<String>,
}
/// The last pushed view + the last level seen, so the feed pushes and fires only on real change
/// (the `ui_char`/`ui_unit` discipline).
///
/// Both are claims about what THIS VM holds, so both sit behind a [`crate::ui_script::VmMemo`]
/// (1290): against a `/reload`'s replacement VM (1291) the memo reads fresh, and the view is
/// re-pushed and its events re-fired for a window the new VM has yet to hear about.
#[derive(Resource, Default)]
struct InspectFeedState {
vm: crate::ui_script::VmMemo<InspectFeedMemo>,
}
/// The per-VM half of [`InspectFeedState`] — the change bases.
#[derive(Default)]
struct InspectFeedMemo {
last: Option<InspectView>,
last_level: Option<u32>,
}
pub(crate) struct InspectUiPlugin;
impl Plugin for InspectUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InspectTarget>()
.init_resource::<InspectFeedState>()
.add_systems(Update, feed_inspect.in_set(UiInput));
}
}
/// One inspected equipment slot, resolved from the target's PUBLIC visible-item entry. `slot0` is
/// the 0-based `EQUIPMENT_SLOT_*` index; the live-API id the Lua sees is `slot0 + 1`.
///
/// Everything an item *object* would supply is absent by construction (see the module doc), so the
/// count is the ref's own always-1 and durability/flags/locks/creator/equip-fit stay at their inert
/// defaults — an inspected item is not draggable, lockable, or repairable.
///
/// The **enchant lines are the one exception** (decision 0915): a unit's descriptor broadcasts the
/// enchants of what it wears — `PLAYER_VISIBLE_ITEM_<slot>_0 + 1 + j` — so an inspected weapon's
/// enchant is readable without any object. 1.12 fills exactly two of those seven slots (PERM,
/// TEMP: vmangos `SetVisibleItemSlot`'s `MAX_INSPECTED_ENCHANTMENT_SLOT`), and that is the same
/// data the reference's own inspect tooltip has.
fn inspect_slot_view(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
slot0: u8,
) -> Option<InvSlotView> {
let entry = store.0.player_visible_item_entry(slot0)?;
// The broadcast roll (`PLAYER_VISIBLE_ITEM_<slot>_PROPERTIES`'s low half — the same `movzx
// WORD` the reference's inspect leg feeds its `+0x424` with): the NAME's suffix, and only
// that. Its *lines* would come from the visible enchant slots 2..6, which 1.12 servers leave
// empty — so an inspected roll names itself and shows no stat lines, on both clients (1547).
let roll = store.0.player_visible_item_properties(slot0);
// Template-only ask (`guid` 0 — `Items::template`'s own documented shape): there is no item
// object to name, and asking by entry is precisely what the real client's ItemCache does.
let (name, quality, display) = match items.template(entry, 0, commands) {
Some(t) => (
Some(rolls.name(&t.name, roll)),
t.quality,
t.display_info_id,
),
None => (None, 0, 0),
};
let link = name
.as_ref()
.map(|n| crate::ui_items::item_link_full(entry, 0, roll, 0, n, quality));
Some(InvSlotView {
item_id: entry,
icon: icons
.and_then(|i| i.catalog.get(display))
.and_then(|d| d.icon.clone()),
count: 1,
quality: quality as i32,
name,
link,
// `already_bound` stays `false` here, deliberately: `0x5da2c0` reads `ITEM_FIELD_FLAGS`
// off an item OBJECT, and an inspected player's gear arrives as descriptor fields on
// THEM — there is no item object and no flags word to read. So an inspect tooltip prints
// the template's own bind line (Binds when equipped), which is what the reference prints
// for the same reason. The enchant half below cannot rescue it: those slots name rows,
// not the instance's bound state.
already_bound: false,
// All 7 slots, exactly as the reference's own inspect leg copies and renders them
// (§E7) — a 1.12 server happens to fill only PERM and TEMP. No item object here, so no
// charges and no `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdown: the reference's inspect
// tooltip has neither either.
enchants: crate::items::enchant_lines(
(0..7).map(|j| {
let id = store.0.player_visible_item_enchant(slot0, j).unwrap_or(0);
(j, id as i32, 0, None)
}),
rolls.enchants,
),
..Default::default()
})
}
#[allow(clippy::too_many_arguments)]
fn feed_inspect(
script: Option<NonSendMut<UiScript>>,
mut target: ResMut<InspectTarget>,
mut feed: ResMut<InspectFeedState>,
mut booth: ResMut<InspectBooth>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the inspected item's enchant line (decision 0915) —
// and `ItemRandomProperties`, the roll behind its "of the Monkey" name (1547).
catalogs: (
Option<Res<crate::items::Enchants>>,
Option<Res<crate::items::RandomProperties>>,
),
commands: Res<NetCommands>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
selection: Res<crate::target::Selection>,
group: Res<crate::ui_party::GroupState>,
) {
let Some(mut script) = script else {
return;
};
// Resolved against THIS VM (1290/1291): a `/reload` keeps the latched token, and the fresh
// memo re-pushes the view into the VM that replaced the one which last saw it.
let memo = feed.vm.get(&script);
// NotifyInspect(unit) → CMSG_INSPECT + latch the token. Resolving here (not in Lua) is what
// lets the window bind to a token while the wire speaks guids.
for token in script.take_inspect_notifies() {
match crate::ui_unit::player_token_guid(&token, &selection, &group) {
Some(guid) => {
debug!("inspect: {token:?} -> {guid:#x}; sending CMSG_INSPECT");
let _ = commands.0.send(ClientCommand::Inspect { target: guid });
target.token = Some(token);
}
None => {
debug!("inspect: {token:?} did not resolve to a player guid — nothing sent");
}
}
}
// ClearInspectPlayer() — the ref's InspectFrame_OnHide. Drop everything so the resolve below
// stops and the booth empties.
if script.take_inspect_clear() {
target.token = None;
}
// Re-resolve the LATCHED TOKEN every frame: `"target"` now means whoever is selected now, which
// is how the reference's retarget arm works (module doc).
let resolved = target.token.as_ref().and_then(|token| {
let guid = crate::ui_unit::player_token_guid(token, &selection, &group)?;
let entity = *index.0.get(&guid)?;
Some((token.clone(), guid, entity))
});
let Some((token, guid, entity)) = resolved else {
// Nothing inspected (or the target isn't streamed): clear the view and empty the booth.
if memo.last.is_some() {
script.set_inspect(None);
memo.last = None;
memo.last_level = None;
}
booth.unit = None;
return;
};
booth.unit = Some(entity);
// The stock `InspectPaperDollFrame.lua` turns the doll by writing the PANE
// (`InspectModelFrame:SetRotation`), the same way the character sheet, the pet doll and the
// stable booth already read theirs (decision 1832).
booth.yaw = script.model_pane_facing("InspectModelFrame");
let Ok(store) = stores.get(entity) else {
return;
};
let mut slots: InventorySlots = Default::default();
for slot in 1..=19u8 {
slots[usize::from(slot)] = inspect_slot_view(
store,
&mut items,
icons.as_deref(),
crate::items::RollCatalogs {
enchants: catalogs.0.as_deref(),
props: catalogs.1.as_deref(),
},
&commands,
slot - 1,
);
}
let view = InspectView {
unit: token.clone(),
guid,
slots,
};
if memo.last.as_ref() != Some(&view) {
script.set_inspect(Some(view.clone()));
memo.last = Some(view);
// The ref's InspectPaperDollItemSlotButton_OnEvent listens for exactly this, filtered on
// arg1 == the inspected unit.
script.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![ScriptValue::Str(token.clone())],
);
}
let level = store.0.unit_level();
if memo.last_level != level {
memo.last_level = level;
script.fire_event("UNIT_LEVEL", vec![ScriptValue::Str(token)]);
}
}