Files
benilla-octo/crates/benilla-ui/src/script/inspect.rs
T
SamandClaude Fable 5 11a0b68da6 party members show on the world map, and the leader wears the crown
- party members appear on the world map at their position and hovering names them — the party wire has carried each member's zone and coordinates since the group arc; the blip and the minimap dot read one shared position so they can never disagree, and the member's zone picks which continent the position belongs to
- the player's own frame wears the leader crown and the master-looter icon, with the reference's own asymmetry (the crown asks IsPartyLeader, the member frames compare a leader index)
- a party member outside your local area gets the generic race/sex portrait instead of an empty circle — their race and sex ride the name query, and nothing had asked for a member we never see; the roster now warms that cache, which also fills the raid grid's class column
- right-clicking a member who is nowhere near grays Trade, Inspect, Duel and Follow: the reference's bindings answer nil for a unit the object manager does not hold (carved this session), where ours defaulted "absent = in range" for exactly the farthest member; and the group-invite popup's background is measured faithful — the plate draws at the BLP's own 60% alpha

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:30:24 +07:00

258 lines
14 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 **inspect** surface (decision 0631) — the intent queues behind the ref's
//! `NotifyInspect`/`ClearInspectPlayer`, and the foreign-unit equipment view the unit-keyed
//! `GetInventoryItem*` family reads through.
//!
//! ## Why there is so little here
//!
//! Inspect is the thinnest of the item windows because **the data is already local**. A player's
//! worn gear rides `PLAYER_VISIBLE_ITEM_<n>_0`, which is `UF_FLAG_PUBLIC` — the server streams it
//! to every observer, and benilla already decodes it to render other players' equipment
//! (`benilla::entities::equipment`). So the inspect window paints from a descriptor we hold, not
//! from a reply we wait for; `SMSG_INSPECT` echoes the guid and nothing else, and no reference
//! handler registers an inspect event. `InspectFrame_Show` calls `NotifyInspect(unit)` and
//! `ShowUIPanel` in the same breath, and `InspectPaperDollFrame_OnShow` reads all 19 slots
//! immediately (`Blizzard_InspectUI.lua:6-13`, `InspectPaperDollFrame.lua:57-79`).
//!
//! ## The seam
//!
//! - **Intents:** `NotifyInspect(unit)` queues the token (the app resolves it → player guid →
//! `CMSG_INSPECT`, which server-side also sets our selection); `ClearInspectPlayer()` flags the
//! drop, the ref's own `InspectFrame_OnHide` call.
//! - **Push:** the app pushes an [`InspectView`] each frame the inspected unit's resolved slot
//! views change ([`UiScript::set_inspect`]), or `None` when nothing is being inspected.
//! - **Read:** *no new item getters.* The reference's own `GetInventoryItemTexture(unit, slot)`
//! family is already unit-keyed; `super::char_stats::player_inv_slot` routes `"player"` to the
//! self feed and the inspected token here.
//! - **Range:** the two verified d² predicates, `CanInspect` and `CheckInteractDistance`, over one
//! app-fed per-token distance map ([`UiScript::set_inspect_reach`]) — the VM holds no positions.
//!
//! The view is keyed by **unit token**, not guid — the ref stores `InspectFrame.unit` as a token
//! and re-reads it on `PLAYER_TARGET_CHANGED`, so an inspect window follows a re-target exactly as
//! the real one does. The `guid` rides along only so the app can tell "same token, different
//! player" apart when it rebuilds.
use std::cmp::Ordering;
use std::collections::HashMap;
use mlua::{Lua, Value};
use super::char_stats::InventorySlots;
use super::Model;
/// The `CheckInteractDistance` threshold table — `{10², 11.1111², 10², 30²}` for the live API's
/// `type ∈ 1..4`, read straight out of the binary (wow-re §5-VERIFIED
/// `PRIMITIVE:check_interact_dist2` @ `0x48ba00`, built from the static `.rdata` at
/// `0x804498`/`0x804490`/`0x80448c`/`0x8044a4`). Type 1 is the *inspect* row's distance, and shares
/// its 100.0 with [`super::inspect`]'s own `CanInspect` threshold.
pub const INTERACT_DIST_SQ: [f64; 4] = [100.0, 123.45678, 100.0, 900.0];
/// The `CanInspect` threshold, **squared** — `DAT_00b4d918`, which the client's own writer builds by
/// squaring the static `.rdata` `10.0` at `0x804498` (wow-re §5-VERIFIED
/// `PRIMITIVE:caninspect_dist2` @ `0x48a1b0`, `ledger.tsv:823`). vmangos enforces the same 10 yards
/// as `INSPECT_DISTANCE` (`ObjectDefines.h:26`), so client and server agree exactly.
pub const CAN_INSPECT_DIST_SQ: f64 = 100.0;
/// What the app resolved about one popup token's unit this frame — the input both range predicates
/// read ([`UiScript::set_inspect_reach`]).
///
/// An entry exists **iff the token resolved to a live unit object**, which is the distinction the
/// reference's own bindings turn on: `0x515940` maps the token to a GUID and then asks the object
/// manager for it, and a NULL there is answered `nil` without any distance being computed. A party
/// member outside the local area is exactly that case — `0x4e81a0` reads their GUID straight out of
/// the roster array, so the GUID is non-zero and the object lookup misses silently.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UnitReach {
/// Squared distance from the player, in the binary's own accumulation shape.
pub dist_sq: f64,
/// The unit passes the two NON-distance refusals vmangos makes for an inspect
/// (`sObjectMgr.GetPlayer` and `!IsValidAttackTarget`, `MiscHandler.cpp:945-956`) — consulted
/// by `CanInspect` alone. `CheckInteractDistance` is a pure distance test in the binary and
/// must not read this, or following an enemy player would gray a row the reference leaves live.
pub inspectable: bool,
}
/// What the app has resolved for the unit currently being inspected (decision 0631).
#[derive(Clone, Debug, PartialEq)]
pub struct InspectView {
/// The unit token the frame is inspecting — the ref's `InspectFrame.unit` (`"target"`,
/// `"party3"`, …). The inventory router matches a binding's `unit` argument against this.
pub unit: String,
/// The player guid the token resolved to when these slots were read.
pub guid: u64,
/// The inspected player's equipment, indexed by live-API inventory slot id exactly like the
/// self feed (1..=19). Index 0 (ammo) and 20..=23 (bags) stay `None` — a foreign player
/// exposes neither, and the ref's inspect paper doll has no button for them.
pub slots: InventorySlots,
}
impl super::UiScript {
/// Push the per-token **inspect reach** map: unit token → squared distance from the player to
/// that unit, for every popup token the app could resolve to a live player this frame.
///
/// One app-fed number serves both range predicates, because the binary uses the same d² for
/// both: `CanInspect` compares it against `100.0`, and `CheckInteractDistance(unit, type)`
/// against [`INTERACT_DIST_SQ`]`[type-1]`. It lives here rather than on
/// [`super::UnitState`] because the popup rows need it for **party** tokens too, and those have
/// no `UnitState` (only `"player"`/`"target"`/`"mouseover"` are fed) — keying it off the unit
/// snapshot would have left the party menu's rows permanently dead.
///
/// A token absent from the map is a token the object manager holds **no unit for**, and both
/// predicates answer `nil` there — the reference's own null-object arm (wow-re
/// `system/ui/scratch/dist2-null-unit-arm.md`, VERIFIED: `0x48babe test ecx,ecx; je` →
/// `lua_pushnil`). It used to read as *in range*, on the reasoning that missing data must not
/// gray a row; the binary says the opposite, and that default was report B316 — every distance
/// row lit up for exactly the party member who was too far away to have an object at all.
pub fn set_inspect_reach(&mut self, reach: HashMap<String, UnitReach>) {
self.model_mut().inspect_reach = reach;
}
/// Push (or clear, with `None`) the inspected unit's resolved equipment view. The app calls
/// this whenever the view changes; it fires no event of its own — the app fires
/// `UNIT_INVENTORY_CHANGED` for the inspected token, which is the signal the ref's
/// `InspectPaperDollItemSlotButton_OnEvent` actually listens for.
pub fn set_inspect(&mut self, view: Option<InspectView>) {
self.model_mut().inspect = view;
}
/// Drain the unit tokens `NotifyInspect` queued — the app resolves each to a player guid and
/// sends `CMSG_INSPECT`.
pub fn take_inspect_notifies(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().inspect_notifies)
}
/// Whether `ClearInspectPlayer` was called since the last drain (and clear the flag) — the
/// app drops its inspect target, which stops the per-frame slot resolve.
pub fn take_inspect_clear(&mut self) -> bool {
std::mem::take(&mut self.model_mut().inspect_clear)
}
/// The inspect model pane's bake yaw in radians — the twin of
/// [`Self::paperdoll_yaw`], read by the app onto the `"inspect"` booth slot each frame.
pub fn inspect_yaw(&self) -> f32 {
self.model_ref().inspect_yaw
}
}
/// What the app resolved for `token`, or `None` when it resolved to no live unit at all — the
/// reference's NULL-object arm, which both predicates answer `nil` to.
fn reach(lua: &Lua, token: &Option<String>) -> Option<UnitReach> {
let token = token.as_deref()?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.inspect_reach.get(token).copied()
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// CanInspect(unit) → 1/nil: the gate the ref's `InspectFrame_Show` opens on
// (`Blizzard_InspectUI.lua:8`). The real client's `0x48a1b0` is a d² range test — wow-re
// §5-VERIFIED `PRIMITIVE:caninspect_dist2`: **out of range iff `threshold < d²`**, where
// `threshold = DAT_00b4d918 = 100.0` (10 yards, the same number vmangos enforces as
// `INSPECT_DISTANCE`). The operator is theirs too: `test ah,0x41; jne` skips the out-of-range
// action on `C0|C3` — Less, Equal, *or unordered* — so the three in-range arms below are that
// mask spelled out, and a NaN distance reads as in RANGE, not out. Only the app-fed distance is
// consulted here; the is-a-player and not-attackable legs are folded in app-side, since a token
// that fails them is never entered in the reach map at all. Decision 0631.
g.set(
"CanInspect",
lua.create_function(|lua, unit: Option<String>| {
let ok = match reach(lua, &unit) {
Some(r) => {
r.inspectable
&& matches!(
r.dist_sq.partial_cmp(&CAN_INSPECT_DIST_SQ),
Some(Ordering::Less | Ordering::Equal) | None
)
}
// No live unit object: the binary reaches its `lua_pushnil` tail through
// `0x4944a0`'s null-`this` guard rather than an early return, but the value it
// pushes is the same one — nothing to inspect.
None => false,
};
Ok(if ok { Value::Integer(1) } else { Value::Nil })
})?,
)?;
// CheckInteractDistance(unit, type) → 1/nil, `type ∈ 1..4` (wow-re §5-VERIFIED
// `PRIMITIVE:check_interact_dist2` @ `0x48ba00`): **in range iff `d² < table[type-1]`** —
// note the STRICT `<` here against `CanInspect`'s non-strict gate above, which is the
// binary's own asymmetry (`test ah,0x5; jp` takes the out path unless `d² < thr` ordered), not
// a transcription slip. The UnitPopup rows' `dist` field indexes it.
//
// The three degenerate arms are the binary's too (`dist2-null-unit-arm.md`, VERIFIED), and
// they are three DIFFERENT answers rather than one permissive default:
//
// - **No live unit for the token** → `nil` (`0x48babe test ecx,ecx; je`). This is the party
// member outside the local area, and answering `1` here was report B316.
// - **`type` numeric but outside 1..4** → `nil`. The compare is UNSIGNED on `trunc(type) − 1`
// (`0x48bac5 cmp esi,0x4; jae`), so `0`, negatives and `≥ 5` all take it, and a fractional
// type truncates toward zero first (`__ftol`: `1.9 → 1`, `0.5 → 0 → nil`).
// - **`type` missing or not a number** → a **script error**, not `nil`
// (`0x48bb48 call luaL_error`, which longjmps and never returns). Same for a non-string
// unit. A caller that passes neither argument is not asking a question the reference
// answers.
g.set(
"CheckInteractDistance",
lua.create_function(|lua, (unit, kind): (Option<String>, Option<f64>)| {
let Some(kind) = kind else {
return Err(mlua::Error::RuntimeError(
"Usage: CheckInteractDistance(\"unit\", distIndex)".into(),
));
};
// `__ftol` chops toward zero; the index is then compared UNSIGNED, so anything that
// is not exactly 1..=4 falls out of the table lookup and answers nil.
let thr = usize::try_from(kind.trunc() as i64)
.ok()
.and_then(|k| k.checked_sub(1))
.and_then(|i| INTERACT_DIST_SQ.get(i).copied());
let ok = match (reach(lua, &unit), thr) {
(Some(r), Some(thr)) => r.dist_sq < thr,
_ => false,
};
Ok(if ok { Value::Integer(1) } else { Value::Nil })
})?,
)?;
// NotifyInspect(unit) — the ref's request verb (`Blizzard_InspectUI.lua:9`). Queues the token;
// the app resolves it → guid → CMSG_INSPECT. The window does NOT wait on the reply (see the
// module doc), so this is fire-and-forget by design, not an unfinished handshake.
g.set(
"NotifyInspect",
lua.create_function(|lua, unit: String| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.inspect_notifies
.push(unit);
Ok(())
})?,
)?;
// ClearInspectPlayer() — the ref calls it from `InspectFrame_OnHide` (l.58) to drop the
// engine's inspected-player state. Ours stops the app's per-frame slot resolve.
g.set(
"ClearInspectPlayer",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.inspect_clear = true;
Ok(())
})?,
)?;
// BenillaInspectModel_SetFacing(radians) — the inspect pane's own bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` (decision 0208 §5's rotate-adjusts-the-bake). Two scalars
// rather than one shared: the two windows can be open at different facings, and the ref's own
// `InspectModelFrame` carries its own `rotation` independent of the character pane's.
g.set(
"BenillaInspectModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.inspect_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}