the shapeshift cloud both ways, and the Honor tab

- druids get the shift cloud back in both directions, by the reference's own mechanism: every shapeshift aura add *or* remove arms a one-slot pending-morph latch that the model-swap rebuild drains and replays — which is why shifting out (a client-side cancel the server sends no visual for) puffs at all, and why a GM morph or a revive correctly shows nothing; the first fix that merely let one-shot effects survive the swap's teardown produced the right pixels for the wrong reason and is superseded
- the honor system lands whole: the Honor tab on the character window, the honor page on inspect, and thirteen missing Lua bindings over a real wire model — session/yesterday/this-week/last-week kills, lifetime HKs and DKs, rank progress and badge art
- the load-bearing find: your current rank and your highest-ever rank are two separately-streamed fields, and the one we already had was the wrong one for anyone who ever de-ranked; a binary cross-check corrected five display details after the build, down to the rank title never being gendered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 18:22:13 +07:00
co-authored by Claude Fable 5
parent f8e312d015
commit 6aa78bc468
43 changed files with 5141 additions and 145 deletions
+1
View File
@@ -174,6 +174,7 @@ pub(crate) use model::Model;
pub use model::TextureProbe;
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua};
+26 -1
View File
@@ -5,7 +5,7 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, guild,
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log,
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, pvp, quest, quest_log,
reputation, session, simplehtml, skills, slider, social, spellbook, taxi, trade, tradeskill,
trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
RegionData, ScriptValue, SoundRequest, UnitState,
@@ -453,6 +453,27 @@ pub(crate) struct Model {
/// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a
/// payload: `CMSG_TOGGLE_PVP` carries no body.
pub(crate) pvp_toggles: u32,
/// The local player's honor snapshot ([`pvp::HonorState`], decision 1512) — the PRIVATE honor
/// descriptor fields the app decodes and pushes ([`super::UiScript::set_honor`]). `None`
/// before the first push, which the six self getters read as a zeroed snapshot (see
/// [`pvp`]'s module doc for why they never return short).
pub(crate) honor: Option<pvp::HonorState>,
/// The last `MSG_INSPECT_HONOR_STATS` reply the app pushed ([`pvp::InspectHonorData`]). Its
/// PRESENCE is `HasInspectHonorData`'s answer — the latch the reference's inspect pane gates
/// its request on — so `None` is a state, not a gap.
pub(crate) inspect_honor: Option<pvp::InspectHonorData>,
/// `RequestInspectHonorData` calls since the app's last
/// [`super::UiScript::take_inspect_honor_requests`] drain. A count for [`Self::pvp_toggles`]'s
/// reason: the binding takes no argument, and the guid the send needs is the app's own inspect
/// target. With [`Self::inspect_honor_pending`] gating the binding it can only ever drain `0`
/// or `1` — the engine has at most one query outstanding.
pub(crate) inspect_honor_requests: u32,
/// The engine's `pending` flag (`0xb71fcc`): a `MSG_INSPECT_HONOR_STATS` query is queued and
/// no reply has landed. `RequestInspectHonorData` (`0x4c80a0`) refuses while it is set, which
/// is what stops a pane shown/hidden/shown in quick succession sending duplicate queries.
/// Cleared by **either** arm of [`super::UiScript::set_inspect_honor`] — a reply landed
/// (`0x4c6f4c`), or the app invalidated the slot (`0x4c6f9d`).
pub(crate) inspect_honor_pending: bool,
/// The two **equipment-display** preferences as the VM currently believes them —
/// `ShowingHelm()` / `ShowingCloak()` ([`worn_display`], decision 1472). Not a setting: the
/// truth is `PLAYER_FLAGS`' `HIDE_HELM`/`HIDE_CLOAK` bits, pushed here on the descriptor edge
@@ -1311,6 +1332,10 @@ impl Model {
follow_requests: Vec::new(),
session_requests: Vec::new(),
pvp_toggles: 0,
honor: None,
inspect_honor: None,
inspect_honor_requests: 0,
inspect_honor_pending: false,
helm_shown: true,
cloak_shown: true,
worn_display_toggles: Vec::new(),
-46
View File
@@ -1,46 +0,0 @@
//! The PvP-flag **Era API surface** (decision 0646) — one global, no state.
//!
//! `TogglePVP()` is the whole outbound half of the PvP family: everything the UI *reads* is unit
//! state that already arrives on the [`super::unit`] snapshot (`UnitIsPVP`, `UnitIsPVPFreeForAll`,
//! `UnitFactionGroup`). The call queues a toggle the app drains
//! ([`super::UiScript::take_pvp_toggles`]) and turns into its `CMSG_TOGGLE_PVP` send, keeping the
//! engine free of ECS/net reach (decision 0068 §3) — [`super::duel`]'s shape, one verb smaller.
//!
//! The reference registers the binding at `0x48d700` and calls it from exactly one place in the
//! whole shipped 1.12 UI: `SlashCmdList["PVP"]` (ChatFrame.lua). benilla's popup row is a
//! deliberate second caller — decision 0646 §3.
use mlua::Lua;
use super::Model;
impl super::UiScript {
/// Drain the PvP-flag toggles queued since the last call — each one is a `CMSG_TOGGLE_PVP`.
/// A count rather than a payload: the packet is empty, so two toggles in a frame are two
/// sends, not one collapsed intent.
pub fn take_pvp_toggles(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().pvp_toggles)
}
/// Queue a toggle from the app side — the `/pvp` slash command. In the reference that slash
/// handler *is* Lua (a one-liner over `TogglePVP`); benilla parses slash lines in Rust, so the
/// same intent enters the same queue here rather than through the global ([`super::duel`]'s
/// `queue_duel_request` reasoning, verbatim).
pub fn queue_pvp_toggle(&mut self) {
self.model_mut().pvp_toggles += 1;
}
}
/// Register the PvP globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// TogglePVP() — /pvp and the unit popup's PvP row. Takes no argument in 1.12: the *state*
// form of the opcode (a one-byte body) has no binding, so there is nothing to pass.
lua.globals().set(
"TogglePVP",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pvp_toggles += 1;
Ok(())
})?,
)
}
+835
View File
@@ -0,0 +1,835 @@
//! The **PvP + honor** surface — `TogglePVP` (decision 0646) and the vanilla honor system's
//! thirteen bindings (decision 1512): the character window's Honor tab, the inspect window's Honor
//! tab, and the rank every unit frame can read off a *foreign* player.
//!
//! ## The three seams
//!
//! The crate's usual split (decision 0068 §3 — no ECS, no wire, no DBC in here):
//!
//! - **Pushed state.** The local player's honor counters are PRIVATE descriptor fields
//! (`PLAYER_FIELD_SESSION_KILLS` … `PLAYER_FIELD_BYTES2`), so the app decodes them and pushes a
//! [`HonorState`] ([`UiScript::set_honor`]); the six self getters read it and nothing else.
//! The inspect pane's numbers are **not** a descriptor: `MSG_INSPECT_HONOR_STATS` (`0x2D6`) is a
//! request/reply pair, so they arrive separately as an [`InspectHonorData`]
//! ([`UiScript::set_inspect_honor`]) — and *whether one is held* is exactly what
//! `HasInspectHonorData` reports, which is the latch the reference's `OnShow` gates its request
//! on. That is why the inspect half is an `Option`, and the self half too — but the `Option` is
//! the **latch only**: the engine's data slots are zero-initialised BSS that every getter reads
//! ungated, so "nothing held" answers **zeros at full width**, never nils and never a short
//! return. The one thing an empty slot changes is `HasInspectHonorData`.
//! - **Intents.** `TogglePVP` queues a `CMSG_TOGGLE_PVP` ([`UiScript::take_pvp_toggles`]) and
//! `RequestInspectHonorData` queues the honor query
//! ([`UiScript::take_inspect_honor_requests`]) — both counts rather than payloads, because
//! neither packet carries anything the VM knows: the toggle has an empty body, and the honor
//! query's body is the *inspected player's* guid, which the app already holds
//! ([`super::inspect`]'s target). Same reasoning as `NotifyInspect`, one argument smaller.
//! The honor query carries a second piece of engine state with it — the **`pending` latch**
//! (`0xb71fcc`), which refuses a second query while one is in flight; see the binding.
//! - **The unit snapshot.** A *foreign* player's rank is knowable at all because `PLAYER_BYTES_3`
//! is a PUBLIC field — byte 3 of it is the CURRENT rank — so it rides
//! [`UnitState::pvp_rank`](super::UnitState::pvp_rank) with the other public per-unit facts and
//! `UnitPVPRank("target")` answers for the inspect pane exactly as the reference's does.
//!
//! ## Two ranks, and neither is the other
//!
//! - **Internal rank**, `0..=19`: the byte on the wire, and the number `GetPVPRankInfo` keys its
//! title lookup on. `0` is "no rank"; `1..=4` are the dishonorable ranks; `5..=18` are
//! Scout/Private through High Warlord/Grand Marshal; `19` is the racial-leader "Leader", which
//! the server sends in `SMSG_PVP_CREDIT` and **`GetPVPRankInfo` refuses** (see below).
//! - **Visual rank**, `-4..=14`: `rank >= 5 ? rank - 4 : rank - 5` (`0x51aa31`) — what
//! `GetPVPRankInfo` *returns* as its second value, and what FrameXML indexes the badge texture by
//! (`"…PvPRank"..format("%02d", rankNumber)`, drawn only when it is positive).
//!
//! **The negative half runs the other way from the server's.** vmangos's `HonorMgr.cpp:991`
//! computes `visualRank = rank > 4 ? rank - 4 : rank * -1`, which gives rank 1 → −1 and rank 4 →
//! −4; the *binding* subtracts 5 instead of negating, so it gives rank 1 → **−4** and rank 4 →
//! **−1**. They genuinely disagree, and the client is the authority for what the client's binding
//! returns — [`visual_rank`] implements the binding. Nothing here reads the server's form, and a
//! reader "fixing" this back to `-rank` would be reverting a §5 cross-check.
//!
//! Conflating the two scales puts Sergeant's badge on a Corporal, so [`visual_rank`] is the one
//! place the conversion happens and every caller goes through it. The trap is that both numbers
//! travel: the badge and `GetPVPRankInfo`'s second return are **visual**, while the wire —
//! `PLAYER_BYTES_3` byte 3, `GetInspectHonorData`'s `lifetimeRank`, and `SMSG_PVP_CREDIT`'s
//! `victimRank` (decision 1512 §2's second-pass correction: `HonorMgr::SendPVPCredit` sends
//! `GetRank().rank`) — is **internal** throughout, which is what lets a rank byte index a
//! `PVP_RANK_*` GlobalString with no conversion at all.
//!
//! ## Why the rank titles come out of the VM's own globals
//!
//! A rank's name is the GlobalString `PVP_RANK_<internal>_<team>` — the engine's one format string
//! (`"PVP_RANK_%d_%d"` @ `0x8445b4`), team `0` Horde and `1` Alliance (VERIFIED against the shipped
//! `FactionGroup.dbc` *and* `GlobalStrings.lua`: `PVP_RANK_5_0` is "Scout", `PVP_RANK_5_1` is
//! "Private"). The real `GlobalStrings.lua` is executed off the player's own install at boot, so
//! those keys are already sitting on `_G` when `GetPVPRankInfo` runs, and reading them there is the
//! only correct answer: a table of titles shipped in this repo would be Blizzard text we may not
//! carry (the contract's install-content rule) *and* wrong in every non-enUS locale. There is **no
//! `PVP_RANK_0_*`** — internal rank 0 names nothing, and both reference panes lean on that nil to
//! fall back to the `NONE` GlobalString, so a missing key answers nil here rather than an empty
//! string or a raise.
//!
//! The team digit is a **tri-state**, not a bool: `0x5efe00` answers `0` Horde, `1` Alliance, or
//! **`-1`** for a unit with no side, and −1 is not special-cased anywhere — it is formatted into
//! the key, `PVP_RANK_9_-1` matches no GlobalString, and the miss *is* the "no title" answer.
//!
//! ## `_FEMALE` is real, and the honor pane never sees it
//!
//! The `_FEMALE` twins ship in `GlobalStrings.lua`, and the engine cannot build their keys: the
//! byte sequence `FEMALE` does not occur anywhere in `WoW.exe`. It reaches them by asking
//! **FrameXML** — `0x703bf0(key, ordinal, gender)` takes a Lua path whenever `gender != 0`, calling
//! `GetText(token, gender, ordinal)`, which appends the suffix for gender 3. So the *suffix* is
//! Lua's and the *request* is the engine's, per call site:
//!
//! - **`GetPVPRankInfo` passes gender 0** (`0x51aa0f`) and takes the fast path — so the character
//! and inspect honor panes render the **male/default title even for a female character**.
//! - **`UnitPVPName`'s title is gendered** by the subject unit (`0x612bf0`), and
//! **`SMSG_PVP_CREDIT`'s is gendered by the LOCAL PLAYER** even though the *team* digit is the
//! victim's (`0x625374`) — the app's chat line owns that asymmetry, through
//! [`UiScript::pvp_rank_title`].
//!
//! So [`rank_title_ungendered`] and [`rank_title_gendered`] are two functions on purpose, and
//! `GetPVPRankInfo` calls the first. **A client that gendered the pane's title would be more
//! "correct" than the reference and would diverge from it**, which is not what we build.
//!
//! ## The counter-intuitive four (wow-re `system/ui/scratch/honor-panel-law.md`, §5, 2026-08-21)
//!
//! Everything below is byte law from that carve, and every one of them looks like a bug:
//!
//! 1. `GetPVPRankProgress` **multiplies by a slightly-wrong f32 reciprocal** and does not clamp —
//! [`rank_progress`].
//! 2. The four dishonorable ranks' visual numbers run **backwards** from the server's — see "Two
//! ranks" above.
//! 3. `GetPVPRankInfo` **rejects rank 19**, whose GlobalString exists and which the credit line and
//! `UnitPVPName` both render.
//! 4. `GetPVPLifetimeStats` **suppresses a highest-lifetime rank below 5**, reporting `0`.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{number_arg, string_arg};
use super::unit::{check_unit_token, is_civilian_kill};
use super::Model;
/// The local player's honor snapshot — every number the character window's Honor tab shows, as the
/// app decoded it from the PRIVATE honor descriptor fields (decision 1512 §1). EXACT shape the app
/// feed is written against; do not rename.
///
/// The kill counters are `u16` because their fields are `TWO_SHORT` descriptors read as halves
/// (`PLAYER_FIELD_SESSION_KILLS` low = honorable, high = dishonorable); the honor totals are the
/// whole-dword `*_CONTRIBUTION` fields.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HonorState {
/// `PLAYER_FIELD_SESSION_KILLS` low half — honorable kills this session.
pub session_hk: u16,
/// `PLAYER_FIELD_SESSION_KILLS` high half — dishonorable kills this session.
pub session_dk: u16,
/// `PLAYER_FIELD_YESTERDAY_KILLS` halves.
pub yesterday_hk: u16,
pub yesterday_dk: u16,
/// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` — yesterday's honor.
pub yesterday_honor: u32,
/// `PLAYER_FIELD_THIS_WEEK_KILLS` low half. There is no this-week DK getter: the reference
/// destructures exactly two values from `GetPVPThisWeekStats`.
pub this_week_hk: u16,
/// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION`.
pub this_week_honor: u32,
/// `PLAYER_FIELD_LAST_WEEK_KILLS` halves.
pub last_week_hk: u16,
pub last_week_dk: u16,
/// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION`.
pub last_week_honor: u32,
/// `PLAYER_FIELD_LAST_WEEK_RANK` — last week's **standing** (the ladder position), which is
/// `GetPVPLastWeekStats`'s fourth return and is not a rank at all.
pub last_week_standing: u32,
/// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…_DISHONORBALE_KILLS` (the server's spellings).
pub lifetime_hk: u32,
pub lifetime_dk: u32,
/// `PLAYER_FIELD_BYTES` byte 3 — the HIGHEST LIFETIME rank, internal scale. PRIVATE, and
/// already decoded elsewhere in the app as the honor-rank byte.
///
/// **Carried raw.** `GetPVPLifetimeStats` suppresses a value below 5 to `0` (`0x51a843`), and
/// that suppression is the *binding's*, not the feed's — push the true byte and let the
/// binding hide it, because the same field read through any other door is not hidden.
pub highest_rank: u8,
/// `PLAYER_BYTES_3` byte 3 — the **current** rank, internal scale, `0` = no rank. The same
/// PUBLIC byte that reaches every observer as [`UnitState::pvp_rank`](super::UnitState).
pub rank: u8,
/// `PLAYER_FIELD_BYTES2` byte 0 — progress through the current rank, `0..=255`. Carried as the
/// raw byte: the fraction is [`rank_progress`]'s business, not the feed's.
pub rank_bar: u8,
}
/// One `MSG_INSPECT_HONOR_STATS` reply (decision 1512 §2), as the app decoded its 50-byte body.
///
/// Fourteen numbers and the guid they were about. **There is no per-period DK here** — the reply
/// carries a session HK/DK pair and then bare HK counts for yesterday/last week/this week (the
/// three `unknownOld` shorts between them are written as zero by the server), which is why
/// `GetInspectHonorData` returns twelve values while the self getters together return more.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct InspectHonorData {
/// The player guid the reply was for — the app's key for "is this still the player we are
/// looking at?". Never returned to Lua; the reference's pane is keyed by its own unit token.
pub guid: u64,
pub session_hk: u16,
pub session_dk: u16,
pub yesterday_hk: u16,
pub yesterday_honor: u32,
pub this_week_hk: u16,
pub this_week_honor: u32,
pub last_week_hk: u16,
pub last_week_honor: u32,
/// The ladder position, the reply's `lastWeekRank` — `GetInspectHonorData`'s ninth return.
pub last_week_standing: u32,
pub lifetime_hk: u32,
pub lifetime_dk: u32,
/// The reply's `highestRank` byte — `GetInspectHonorData`'s twelfth return, which the
/// reference names `lifetimeRank`. Internal scale, like every other rank here.
///
/// **Passed through, where its self-side twin is suppressed below 5.** The carve pins
/// `GetPVPLifetimeStats`'s `cmp al,5; jb` at `0x51a843` and records `0x4c9620` as twelve plain
/// pushes with no such gate, so this one is not filtered here. That is a *recorded absence*
/// rather than an asserted negative: if a re-read of `0x4c9620` ever turns up the same
/// compare, this field's getter is the one place it belongs — and nothing else should be
/// harmonised to match it in the meantime.
pub highest_rank: u8,
/// The reply's trailing `rankBar` byte — read only by `GetInspectPVPRankProgress`.
pub rank_bar: u8,
}
impl super::UiScript {
/// Drain the PvP-flag toggles queued since the last call — each one is a `CMSG_TOGGLE_PVP`.
/// A count rather than a payload: the packet is empty, so two toggles in a frame are two
/// sends, not one collapsed intent.
pub fn take_pvp_toggles(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().pvp_toggles)
}
/// Queue a toggle from the app side — the `/pvp` slash command. In the reference that slash
/// handler *is* Lua (a one-liner over `TogglePVP`); benilla parses slash lines in Rust, so the
/// same intent enters the same queue here rather than through the global ([`super::duel`]'s
/// `queue_duel_request` reasoning, verbatim).
pub fn queue_pvp_toggle(&mut self) {
self.model_mut().pvp_toggles += 1;
}
/// Push (or clear, with `None`) the local player's honor snapshot — the six self getters'
/// only source. Pushed on the honor descriptor edge, not per frame; it fires no event of its
/// own, because the events the reference's pane listens for (`PLAYER_PVP_KILLS_CHANGED`,
/// `PLAYER_PVP_RANK_CHANGED`) are the app's to fire alongside the push.
pub fn set_honor(&mut self, honor: Option<HonorState>) {
self.model_mut().honor = honor;
}
/// Push (or clear, with `None`) the last `MSG_INSPECT_HONOR_STATS` reply.
///
/// **The presence of this is `HasInspectHonorData`'s answer**, so clearing it is as
/// load-bearing as pushing it: the app clears when it drops the inspected player
/// (`ClearInspectPlayer`), which is what makes the reference's `OnShow` re-request for the
/// next one instead of painting the previous player's numbers. Pair it with the pane's
/// `INSPECT_HONOR_UPDATE` event.
///
/// **Both arms clear the in-flight latch**, exactly as the engine's two writers do: a reply
/// landing zeroes `pending` at `0x4c6f4c`, and re-keying or clearing the slot zeroes it at
/// `0x4c6f9d`. So an invalidation also says "whatever was in flight is no longer wanted",
/// which is what lets the *next* `RequestInspectHonorData` through.
pub fn set_inspect_honor(&mut self, data: Option<InspectHonorData>) {
let mut model = self.model_mut();
model.inspect_honor = data;
model.inspect_honor_pending = false;
}
/// Drain the `RequestInspectHonorData` calls queued since the last call — each one is a
/// `MSG_INSPECT_HONOR_STATS` send carrying the app's current inspect target guid.
///
/// A count, for [`Self::take_pvp_toggles`]'s reason: the binding takes no argument, so there
/// is no payload to carry, and the request the *app* addresses is its own inspect target. It
/// drains **0 or 1** and never more, because the binding's `pending` latch (`0x4c80b6`)
/// refuses a second query until [`Self::set_inspect_honor`] resolves the first — a count kept
/// rather than a `bool` because that latch is the engine's, not this signature's, and the app
/// loop over it is identical either way.
pub fn take_inspect_honor_requests(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().inspect_honor_requests)
}
/// A PvP rank's localized title for the app's `SMSG_PVP_CREDIT` chat line — the
/// `PVP_RANK_<rank>_<team>` GlobalString off the VM's own globals (`team` `0` Horde, `1`
/// Alliance), **gendered**: the `_FEMALE` twin when `female`, falling back to the base key.
/// A missing or empty key is `None`, never an empty string.
///
/// **Gendered is right *here* and wrong in the pane**, which is the whole reason this is not
/// simply `GetPVPRankInfo`'s lookup: the credit formatter resolves through `0x612bf0`
/// (§4.3-VERIFIED) while `GetPVPRankInfo` passes gender 0. It shares [`rank_title_gendered`]
/// with `UnitPVPName`, the engine's other gendered caller.
///
/// The number `SMSG_PVP_CREDIT` carries is the **INTERNAL** rank — the GlobalString index
/// itself, which is precisely why it can be passed straight in — and NOT the visual rank the
/// badge indexes; see the module doc's "Two ranks". Unlike the pane's binding this path applies
/// **no range check**, so rank 19 legitimately names "Leader" here.
///
/// Two facts the caller owns, both from the same carve, because neither is expressible in this
/// signature: the gender is the **local player's** while the team is the **victim's**
/// (`0x625374` hands `0x612bf0` the entry `this`, still the local player), and a victim whose
/// team resolves to **−1 emits no line at all** (`0x625321 js` bails the whole formatter) —
/// which is a `u8` here precisely so that decision cannot be smuggled into a key that misses.
pub fn pvp_rank_title(&self, rank: u8, team: u8, female: bool) -> Option<String> {
rank_title_gendered(self.lua(), i64::from(rank), i64::from(team), female)
}
}
/// Internal rank → **visual** rank, `0x51aa31`'s two-legged subtraction:
///
/// ```text
/// 51aa31 cmp edi,0x5
/// 51aa36 jge 0x51aa55 ; SIGNED
/// 51aa38 edi += -5 ; ranks 1..4 -> -4 .. -1
/// 51aa55 edi += -4 ; ranks 5..18 -> 1 .. 14
/// ```
///
/// Defined on `1..=18` only, because that is the whole domain that reaches it: `GetPVPRankInfo`
/// range-gates first and answers `0` for everything outside, so there is no "rank 0 maps to 0" case
/// to write here — the badge stays off an unranked player through the gate, not through this.
///
/// The negative leg **subtracts 5**; it does not negate. See the module doc — the server's
/// `visualRank` negates and the two therefore disagree for exactly ranks 1..4.
fn visual_rank(rank: i64) -> i64 {
if rank >= 5 {
rank - 4
} else {
rank - 5
}
}
/// The f32 constant at `0x8026c8` — the four bytes `81 80 80 3b` — that `GetPVPRankProgress` and
/// `GetInspectPVPRankProgress` multiply the rank-bar byte by. It is the f32 *nearest* `1/255`
/// (`0.003921568859368563`, against `1/255`'s `0.00392156862745098`) and it is **not** `1/255`.
const RANK_BAR_SCALE: f64 = f32::from_bits(0x3B80_8081) as f64;
/// The rank progress bar's byte → the fraction the reference feeds straight to a `StatusBar` whose
/// `minValue`/`maxValue` are `0`/`1`.
///
/// **A multiply by [`RANK_BAR_SCALE`], not a division by 255, and no clamp** — both halves are the
/// §5 verdict correcting the natural reading, and both are deliberate:
///
/// - `0x51aace` is `fild DWORD` then `fmul DWORD PTR ds:0x8026c8`. Under the client's PC_53 x87
/// invariant `(double)b * (double)K` and `(double)b / 255.0` differ for **255 of the 256** byte
/// values (all but 0), so `bar as f64 / 255.0` is bit-wrong on every non-zero input. A byte of
/// 255 comes out as `1.0000000091389835`, *above* 1.0, and a byte of 51 is not exactly 0.2.
/// - There is no `fcom`, no min/max and no compare anywhere in either function. The value is
/// `byte * K` and nothing else. Clamping would be tidier and would be a divergence — and it
/// would hide precisely the >1.0 overshoot that proves the multiply.
///
/// A full bar therefore hands `StatusBar:SetValue` a number a hair over its `maxValue`. That is
/// what the real client does; how our `StatusBar` treats an out-of-range value is that widget's
/// business, not this binding's.
fn rank_progress(bar: u8) -> f64 {
f64::from(bar) * RANK_BAR_SCALE
}
/// The local player's honor snapshot, or `None` before the first push.
fn honor(lua: &Lua) -> Option<HonorState> {
lua.app_data_ref::<Model>().expect("model app_data").honor
}
/// The last inspect-honor reply, or `None` when none is held.
fn inspect_honor(lua: &Lua) -> Option<InspectHonorData> {
lua.app_data_ref::<Model>()
.expect("model app_data")
.inspect_honor
}
/// A GlobalString off the VM's own globals, `None` when it is absent **or empty** — the
/// no-install / bare-VM path. (`GetPVPRankInfo` tests both explicitly: `0x51aa1c` NULL,
/// `0x51aa20` empty first byte.)
fn global_string(lua: &Lua, key: &str) -> Option<String> {
lua.globals()
.get::<Option<String>>(key)
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
/// A unit's PvP **team digit** — `0x5efe00`'s tri-state: `0` Horde, `1` Alliance, `-1` for a unit
/// with no side (a neutral/monster faction template, or a race the tables can't resolve).
///
/// The engine walks race → `ChrRaces` → `FactionTemplate` and tests `[rec+0xc]`'s factionGroupMask
/// `& 4` → 0, `& 2` → 1, else −1. This crate holds no DBC (decision 0068 §3), so the same answer
/// arrives pre-resolved on the snapshot as [`UnitState::faction_group`](super::UnitState) — the
/// app resolves it from `UNIT_FIELD_FACTIONTEMPLATE` through the same mask. Different hop, same
/// output space (the credit line's own inline copy at `0x62530a` reads the template one hop
/// earlier than `0x5efe00` does and lands in the same three values).
///
/// −1 needs no special case: it formats into the key, and `PVP_RANK_9_-1` matches no GlobalString.
fn team_of(u: &super::UnitState) -> i64 {
match u.faction_group.as_deref() {
Some("Horde") => 0,
Some("Alliance") => 1,
_ => -1,
}
}
/// `GetPVPRankInfo`'s **second argument** → the team digit, the engine's three-way dispatch
/// (`0x51a98c` / `0x51a9af` / `0x51a9c8`, converging on `0x51a9ee mov ebx,eax`):
///
/// | argument | team |
/// |---|---|
/// | a **number** | the number itself, truncated toward zero — no unit is resolved at all |
/// | a **string** | that unit token's [`team_of`], or **0** if the token names nothing or names a non-player |
/// | absent / anything else | the local player's [`team_of`], or **0** if there is no player |
///
/// The 0 on the two "not found" edges is the team register's initial value, not a decision — so a
/// rank asked for on a client with no player snapshot is named off the HORDE list. That is the
/// engine's behaviour and it is deliberately reproduced; a unit that *is* found but has no side
/// answers −1 instead, and −1 misses the key ([`team_of`]).
///
/// An unrecognised token raises `Unknown unit name`, as `0x515970` does for every unit binding.
fn team_arg(lua: &Lua, v: Value) -> mlua::Result<i64> {
// `lua_isnumber` before `lua_isstring`, which is what puts a numeric string on the number arm.
if let Some(n) = lua.coerce_number(v.clone())? {
return Ok(i64::from(n as i64 as i32));
}
let Some(token) = lua.coerce_string(v)? else {
// Absent, boolean, table — the local-player preamble.
let model = lua.app_data_ref::<Model>().expect("model app_data");
return Ok(model.unit("player").map_or(0, team_of));
};
let token = Some(token.to_str()?.to_owned());
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(token
.as_ref()
.and_then(|t| model.unit(t))
// `0x51a9af`'s `shr edx,4; test dl,1` player gate. Applied here, unlike in `UnitPVPRank`
// (see there), because on this arm nothing else can tell a creature from a player: a
// PvP-flagged city guard's faction template carries Player|<side> too, so its
// `faction_group` would otherwise name a side the engine answers 0 for. The cost is the
// mirror-image miss — an un-enriched player snapshot reads as a non-player — which lands
// on a different title rather than on none, and this arm is addon surface: neither pane
// passes a second argument at all.
.filter(|u| u.is_player)
.map_or(0, team_of))
}
/// A rank's localized title, read out of the VM's own globals (see the module doc):
/// `PVP_RANK_<internal>_<team>`, **ungendered** — `0x703bf0(key, -1, 0)`, the fast path that never
/// enters Lua.
///
/// This is `GetPVPRankInfo`'s lookup (`0x51aa0f push 0`), and the reason the honor panes show a
/// male/default title to a female character. Do not "fix" it; see the module doc's `_FEMALE`
/// section.
fn rank_title_ungendered(lua: &Lua, rank: i64, team: i64) -> Option<String> {
global_string(lua, &format!("PVP_RANK_{rank}_{team}"))
}
/// The same title, **gendered** by the subject — `0x612bf0`: it hands the gender selector (2 male /
/// 3 female) to `0x703bf0`, which routes into FrameXML's `GetText` and gets `_FEMALE` appended for
/// 3, then **retries ungendered** when that misses (`0x612c2d`).
///
/// Two callers, both verified to be gendered: `UnitPVPName`'s decoration (by the *subject unit*)
/// and [`UiScript::pvp_rank_title`], the app's `SMSG_PVP_CREDIT` line (by the *local player*, even
/// though the team digit is the victim's — `0x625374`).
fn rank_title_gendered(lua: &Lua, rank: i64, team: i64, female: bool) -> Option<String> {
female
.then(|| global_string(lua, &format!("PVP_RANK_{rank}_{team}_FEMALE")))
.flatten()
.or_else(|| rank_title_ungendered(lua, rank, team))
}
/// Fill a **two-`%s` C format string** — the shape `snprintf(buf, size, GlobalString(...), a, b)`
/// needs, and no more than that shape: the engine pushes exactly two varargs (`0x6093f2 add
/// esp,0x14`), so any further specifier in a locale's template would read uninitialised stack
/// there and is simply dropped here.
///
/// It substitutes rather than hardcoding `"{a} {b}"` because the template is *install data*: enUS
/// ships `UNIT_PVP_NAME = "%s %s"`, and a locale that punctuates or spaces it differently must come
/// out differently. `%%` is a literal `%`; anything else after a `%` is passed through untouched.
fn format_two_strings(fmt: &str, a: &str, b: &str) -> String {
let mut out = String::with_capacity(fmt.len() + a.len() + b.len());
let mut args = [a, b].into_iter();
let mut chars = fmt.chars();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
match chars.next() {
Some('s') => out.push_str(args.next().unwrap_or("")),
Some('%') => out.push('%'),
Some(other) => {
out.push('%');
out.push(other);
}
None => out.push('%'),
}
}
out
}
/// `0x609370` — the name builder behind `UnitPVPName`, called with its flag set. `name` is the
/// unit's plain `UnitName`; see the binding for the three legs and the two divergences.
fn pvp_name(lua: &Lua, u: &super::UnitState, name: &str, player_level: u32) -> String {
// Leg A: a player with a rank. The title is gendered by THIS unit and range-unchecked.
if u.is_player && u.pvp_rank != 0 {
let title = rank_title_gendered(lua, i64::from(u.pvp_rank), team_of(u), u.sex == 3);
if let (Some(fmt), Some(title)) = (global_string(lua, "UNIT_PVP_NAME"), title) {
let mut out = format_two_strings(&fmt, &title, name);
// Leg A′: the city-protector medal, on its own line, ungendered (`0x60941d push 0`).
if u.pvp_medal != 0 {
if let Some(medal) = global_string(lua, &format!("PVP_MEDAL{}", u.pvp_medal)) {
out.push('\n');
out.push_str(&medal);
}
}
return out;
}
return name.to_owned();
}
// Leg B: the dishonorable-kill warning, on the same predicate the tooltip's CIVILIAN line uses.
if is_civilian_kill(u, player_level) {
if let Some(civilian) = global_string(lua, "PVP_RANK_CIVILIAN") {
return format!("{civilian} {name}");
}
}
// Leg C.
name.to_owned()
}
/// Register the PvP + honor globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// TogglePVP() — /pvp and the unit popup's PvP row. Takes no argument in 1.12: the *state*
// form of the opcode (a one-byte body) has no binding, so there is nothing to pass. The
// reference registers it at `0x48d700` and calls it from exactly one place in the whole
// shipped 1.12 UI: `SlashCmdList["PVP"]` (ChatFrame.lua); benilla's popup row is a deliberate
// second caller — decision 0646 §3.
g.set(
"TogglePVP",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pvp_toggles += 1;
Ok(())
})?,
)?;
// ── The self getters ─────────────────────────────────────────────────────────────────────
// Every one of them answers at full arity on every path, zeros included: the reference
// destructures the return straight into `format()` arguments, so a short return would leave a
// pane painting nils where the real client paints `0`. Before the first push (`None`) they
// therefore read as a zeroed snapshot — which is also exactly what a fresh character has.
// GetPVPSessionStats() → hk, dk.
g.set(
"GetPVPSessionStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((i64::from(h.session_hk), i64::from(h.session_dk)))
})?,
)?;
// GetPVPYesterdayStats() → hk, dk, contribution (THREE — the weekly pair below is different).
g.set(
"GetPVPYesterdayStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((
i64::from(h.yesterday_hk),
i64::from(h.yesterday_dk),
i64::from(h.yesterday_honor),
))
})?,
)?;
// GetPVPThisWeekStats() → hk, contribution. **Two, not three**: the reference destructures
// `local hk, contribution = GetPVPThisWeekStats()` and shows no this-week DK column at all.
g.set(
"GetPVPThisWeekStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((i64::from(h.this_week_hk), i64::from(h.this_week_honor)))
})?,
)?;
// GetPVPLastWeekStats() → hk, dk, contribution, standing. **Four**: the fourth is
// `PLAYER_FIELD_LAST_WEEK_RANK`, which is the ladder STANDING, not a rank — the pane prints it
// through `format(PVP_RANK_LAST_WEEK, standing)` and never looks a title up for it.
g.set(
"GetPVPLastWeekStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
Ok((
i64::from(h.last_week_hk),
i64::from(h.last_week_dk),
i64::from(h.last_week_honor),
i64::from(h.last_week_standing),
))
})?,
)?;
// GetPVPLifetimeStats() → hk, dk, highestRank. The third is the HIGHEST LIFETIME rank
// (`PLAYER_FIELD_BYTES` byte 3), internal scale — not the current one `UnitPVPRank` answers.
//
// **A highest rank below 5 is reported as 0, not as itself** (`0x51a843 cmp al,5; jb`,
// UNSIGNED, falling into the same `0.0` push the absent-player tail uses). Ranks 1..4 are the
// dishonorable titles, and this is why the reference pane shows NONE for a character whose
// best was one of them: the suppression is here, in the binding, not in the pane's Lua.
// Suppressed to 0 — not to nil — so the pane's `GetPVPRankInfo(highestRank)` still gets a
// number.
g.set(
"GetPVPLifetimeStats",
lua.create_function(|lua, ()| {
let h = honor(lua).unwrap_or_default();
let highest = if h.highest_rank >= 5 {
h.highest_rank
} else {
0
};
Ok((
i64::from(h.lifetime_hk),
i64::from(h.lifetime_dk),
i64::from(highest),
))
})?,
)?;
// GetPVPRankProgress() → the bar fraction. A multiply, and unclamped — see `rank_progress`,
// which is the whole law of this binding. Absent player → `0.0`, still one value.
g.set(
"GetPVPRankProgress",
lua.create_function(|lua, ()| Ok(rank_progress(honor(lua).unwrap_or_default().rank_bar)))?,
)?;
// GetPVPRankInfo(rank [, unit]) → rankName, rankNumber — the title off the VM's globals
// (module doc) and the VISUAL rank. **Always two values**: every failure edge answers
// `nil, 0`, and that nil is what both reference panes branch on to substitute the `NONE`
// GlobalString. The first argument is the INTERNAL rank, which is what `UnitPVPRank` and the
// `*LifetimeStats`/`InspectHonorData` rank returns all carry.
//
// Three things the name gives no hint of, all `0x51a930`:
//
// * **The accepted range is [1, 18], SIGNED** (`0x51a9f0 cmp edi,1; jl` / `0x51a9f5 cmp
// edi,0x12; jg`). Rank 0 fails it, and so does **rank 19** — the racial-leader "Leader",
// whose `PVP_RANK_19_*` GlobalStrings exist and which the server really does send in
// `SMSG_PVP_CREDIT`. The pane simply cannot name it. That rejection is why the engine's own
// badge table has fifteen entries while FrameXML only ever indexes fourteen: rank 19 →
// `PvPRank15` is reachable from the world-text kill toast (`0x6c7f10`, badge index
// `rank − 5`) and from nowhere on this path.
// * **The second argument exists**, and it is a three-way dispatch, not a unit token: a
// NUMBER is the team digit itself (no unit resolution at all), a STRING is a unit token, and
// absent-or-anything-else means the local player. Lua's own `isnumber` accepts a numeric
// string, so `"1"` takes the number arm and `"player"` the token arm — `coerce_number`
// first is that same order.
// * **A team the engine cannot resolve is 0, not a failure.** An unresolvable token, a
// non-player unit, or no local player leaves the team register at its initial 0 — the HORDE
// list — while a unit that resolves but has no *side* gets −1 and misses the key. The two
// are different answers and this reproduces both.
g.set(
"GetPVPRankInfo",
lua.create_function(|lua, (rank, unit): (Value, Value)| {
let rank = i64::from(number_arg(
lua,
rank,
"Usage: GetPVPRankInfo(rank [, unit])",
)?);
let team = team_arg(lua, unit)?;
// The range gate first: outside [1, 18] no key is even built.
let name = if (1..=18).contains(&rank) {
rank_title_ungendered(lua, rank, team)
} else {
None
};
let Some(name) = name else {
return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Integer(0)]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&name)?),
Value::Integer(visual_rank(rank)),
]))
})?,
)?;
// UnitPVPRank(unit) → the unit's CURRENT rank, internal scale, 0 for no rank.
//
// It answers for a FOREIGN unit on purpose: `0x51a8a0` resolves the named token and reads
// **that object's own** `[+0xe68]+0x1f` — no cache, no local-player special case — and the byte
// lives in `PLAYER_BYTES_3`, a PUBLIC descriptor field the server streams to every observer.
// That is why the reference's inspect pane can call `UnitPVPRank("target")` at all, and why a
// foreign rank never comes from the inspect reply.
//
// A creature, a non-player, and an unresolvable token all read **0 — a number, never nil**
// (`0x51a916`), so a genuine rank-0 player and an unknown token are indistinguishable to
// script. A **non-string argument raises** (`0x51a8ac`), which is why this takes its token
// through `string_arg` rather than an `Option`: `UnitPVPRank()` abandons the caller's
// statement, it does not answer 0.
//
// **The engine's player gate (`0x51a8e1 shr edx,4; test dl,1`) is deliberately NOT re-applied
// here**, and the reason is a real one rather than laziness: the engine gates on the object's
// TYPE MASK, and our nearest equivalent — `UnitState::is_player` — is not that. It is filled by
// the app's *guid-keyed enrichment* step, one hop after the descriptor snapshot the rank byte
// itself rides on, so a snapshot pushed without enrichment would read as a non-player and
// silently zero a real player's rank in a pane that repaints every event. The creature case
// needs no gate anyway: a creature has no PLAYER descriptor block, so the byte the app decodes
// is already 0 — the reference's own answer for one, by its own path.
g.set(
"UnitPVPRank",
lua.create_function(|lua, token: Value| {
let token = Some(string_arg(lua, token, "Usage: UnitPVPRank(\"unit\")")?);
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(token
.as_ref()
.and_then(|t| model.unit(t))
.map_or(0i64, |u| i64::from(u.pvp_rank)))
})?,
)?;
// UnitPVPName(unit) → the unit's name, rank-title-decorated — `0x5172b0` into the builder
// `0x609370`, whose three legs are:
//
// A a PLAYER with a non-zero rank byte → snprintf(GlobalString("UNIT_PVP_NAME"),
// rankName, plainName). The `add esp,0x14` at `0x6093f2` proves exactly two varargs, and
// the shipped template is "%s %s" — RANK FIRST. The title is `0x5efe60` → `0x612bf0`, so
// it is GENDERED by this unit (unlike the pane's, module doc) and has NO range check, so
// a rank-19 racial leader really does render as "Leader Whoever".
// A′ …and if PLAYER_BYTES_3 byte 2 (the city-protector title) is non-zero, a second line:
// "\n" + GlobalString("PVP_MEDAL<n>"), ungendered.
// B otherwise, a unit that passes the CIVILIAN predicate `0x612550` →
// GlobalString("PVP_RANK_CIVILIAN") + " " + plainName. Reached by creatures: leg A takes
// every ranked player first, and our snapshot only ever flags creatures civilian.
// C otherwise the plain name.
//
// Two edges answer **nil**: no snapshot for the token, and a snapshot whose name has not
// resolved. The engine distinguishes a third — a token resolving to GUID 0 returns one value
// having pushed nothing (`0x517343`), i.e. non-nil and unspecified. That is an INFERRED
// reading of a `luaD_poscall` detail, not a fact about this function, and reproducing an
// unspecified value is not something a client can honestly do: we answer nil there too.
//
// The no-install divergence, stated rather than hidden: with `UNIT_PVP_NAME` or the rank title
// missing from `_G`, the engine would snprintf through an empty format and hand back an empty
// string. We answer the plain name instead — the decoration is install data, and without the
// data there is no decoration.
g.set(
"UnitPVPName",
lua.create_function(|lua, token: Value| {
let token = Some(string_arg(lua, token, "Usage: UnitPVPName(\"unit\")")?);
check_unit_token(&token)?;
let (unit, player_level) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
(
token.as_ref().and_then(|t| model.unit(t)).cloned(),
model.player_req.level,
)
};
let Some(u) = unit else {
return Ok(Value::Nil);
};
let Some(name) = u.name.clone() else {
return Ok(Value::Nil);
};
let decorated = pvp_name(lua, &u, &name, player_level);
Ok(Value::String(lua.create_string(&decorated)?))
})?,
)?;
// ── The inspect half ─────────────────────────────────────────────────────────────────────
// RequestInspectHonorData() — the reference's `InspectHonorFrame_OnShow` calls it iff
// `HasInspectHonorData()` is false. Queues the intent; the app pairs it with the guid it is
// already inspecting and sends `MSG_INSPECT_HONOR_STATS` (see `NotifyInspect`, which is the
// same shape with a token to carry). Zero Lua return values on every path (`0x4c9610`).
//
// **`0x4c80a0` has three silent gates**, and they are the binding's, not the pane's: bail if
// data is already held (`0x4c80a6`), bail if a query is already in flight (`0x4c80b6`), bail
// if the target guid is zero (`0x4c80c2`). Two of the three are ours to keep:
//
// * `hasData` — modelled here even though the reference's own `OnShow` tests it first,
// because it is the engine that refuses; a pane (or addon) that asks anyway must get the
// engine's silence, not a second query.
// * `pending` — modelled here, and this is the one that earns its keep: it is what stops a
// pane shown/hidden/shown before the reply lands sending duplicates.
// * the guid — **not** modelled here and it cannot be: this crate never sees a guid
// (decision 0068 §3). The app drops a queued request with no inspect target, which is the
// same outcome one seam further out. The cost is that our `pending` latches on *queue*
// where the engine's latches on *send*, so a request the app drops leaves the latch set
// until the next `set_inspect_honor` — which the app's own `ClearInspectPlayer` path
// calls, exactly as `0x4c6f70` clears the engine's.
g.set(
"RequestInspectHonorData",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.inspect_honor.is_some() || model.inspect_honor_pending {
return Ok(());
}
model.inspect_honor_requests += 1;
model.inspect_honor_pending = true;
Ok(())
})?,
)?;
// HasInspectHonorData() → 1/nil. The latch the request above is gated on: true exactly while a
// reply is held, so dropping the inspected player (which clears the push) makes the next
// `OnShow` re-request instead of repainting the last player's numbers.
g.set(
"HasInspectHonorData",
lua.create_function(|lua, ()| {
Ok(if inspect_honor(lua).is_some() {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetInspectHonorData() → the reference's twelve, in its own order:
// sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisweekHK, thisweekHonor,
// lastweekHK, lastweekHonor, lastweekStanding, lifetimeHK, lifetimeDK, lifetimeRank
// Note there is no per-period DK past the session pair — the reply carries yesterday/last
// week/this week DK halves (vmangos's `unknownOld1/2/3`) and the client surfaces none of them.
//
// **Twelve on every path, zeros included.** `0x4c9620` is UNGATED — it never consults the
// has-data flag; it reads sixteen zero-initialised BSS globals, so a client with no reply held
// answers twelve zeros, not nils and not a short return. (The engine's slots are also never
// *cleared*: re-keying the cache zeroes the two flags and leaves the data, so the real client
// can answer a previous target's numbers here while `HasInspectHonorData` says no. We cannot —
// our data and our latch are one `Option` — and that is the one place this pair diverges: it
// needs the app to own the flag separately, which is a seam change, not a fix here.)
g.set(
"GetInspectHonorData",
lua.create_function(|lua, ()| {
let d = inspect_honor(lua).unwrap_or_default();
let out = vec![
Value::Integer(i64::from(d.session_hk)),
Value::Integer(i64::from(d.session_dk)),
Value::Integer(i64::from(d.yesterday_hk)),
Value::Integer(i64::from(d.yesterday_honor)),
Value::Integer(i64::from(d.this_week_hk)),
Value::Integer(i64::from(d.this_week_honor)),
Value::Integer(i64::from(d.last_week_hk)),
Value::Integer(i64::from(d.last_week_honor)),
Value::Integer(i64::from(d.last_week_standing)),
Value::Integer(i64::from(d.lifetime_hk)),
Value::Integer(i64::from(d.lifetime_dk)),
Value::Integer(i64::from(d.highest_rank)),
];
debug_assert_eq!(out.len(), 12, "the tuple is twelve wide on every path");
Ok(MultiValue::from_vec(out))
})?,
)?;
// GetInspectPVPRankProgress() → the same bar fraction as GetPVPRankProgress, off the reply's
// trailing `rankBar` byte. `0x51ab00` is the same three-instruction kernel over the cached
// byte — the `fild`/`fmul ds:0x8026c8`/`fstp` at `0x51ab04` is byte-identical to `0x51aace` —
// with no object lookup, no gate and no clamp, so it too reads 0.0 from an empty slot rather
// than nil ([`rank_progress`]).
g.set(
"GetInspectPVPRankProgress",
lua.create_function(|lua, ()| {
Ok(rank_progress(
inspect_honor(lua).unwrap_or_default().rank_bar,
))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -31,7 +31,7 @@ use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empt
// The grey band + trivial/GREY check (`0x5f0700`, the CIVILIAN line's last gate: a green-or-
// better con never warns of a dishonorable kill) and the "??" gate live in one shared home
// (`unit.rs`), alongside `UnitLevel`'s −1 return and the `GetQuestGreenRange` binding.
use super::unit::{level_reads_unknown, unit_is_grey};
use super::unit::{is_civilian_kill, level_reads_unknown};
use super::{KindState, Model, UnitState};
use crate::layout::{Anchor, Point};
use crate::widget::FrameHandle;
@@ -168,13 +168,9 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
}
// CIVILIAN (green) — `0x612550`, whole: the unit's PvP bit + the query civilian flag + the
// unit is HOSTILE to the player (UnitReaction ≤ 2 — internal reaction < 2) + the kill would
// be GREY/trivial. PVP_RANK_CIVILIAN = "Civilian" (extracted GlobalStrings).
if u.civilian
&& u.pvp
&& u.reaction != 0
&& u.reaction <= 2
&& unit_is_grey(player_level, u.level)
{
// be GREY/trivial. PVP_RANK_CIVILIAN = "Civilian" (extracted GlobalStrings). The predicate
// itself lives in `unit` because `UnitPVPName` gates its own civilian arm on the same call.
if is_civilian_kill(&u, player_level) {
append_line(lua, this, ("Civilian".into(), GREEN), None, false)?;
}
// LEADER (white) — `0x6125c0`: the PvP bit + the query racial_leader flag, no other gate.
+40
View File
@@ -146,6 +146,27 @@ pub struct UnitState {
/// bit) — distinct from [`Self::pvp`]'s ordinary faction PvP flag; a unit can carry either, both,
/// or neither independently (an FFA zone flags this without the ordinary flag ever setting).
pub is_pvp_ffa: bool,
/// The unit's **current** PvP rank on the internal `0..=18` scale, `0` = no rank
/// (`UnitPVPRank`; decision 1512). `PLAYER_BYTES_3` **byte 3** — a **PUBLIC** descriptor field,
/// which is the whole reason a foreign player's rank is knowable and why the reference's
/// inspect pane can call `UnitPVPRank("target")`. Not to be confused with the HIGHEST LIFETIME
/// rank (`PLAYER_FIELD_BYTES` byte 3), which is private, rides
/// [`HonorState`](super::HonorState) instead, and is what `GetPVPLifetimeStats` reports.
///
/// Internal, not visual: the badge texture and `GetPVPRankInfo`'s second return are the
/// `-4..=14` visual scale, converted in [`super::pvp`] and nowhere else.
pub pvp_rank: u8,
/// The unit's **city-protector title** index — `PLAYER_BYTES_3` **byte 2**, the byte next door
/// to [`Self::pvp_rank`] (vmangos `PLAYER_BYTES_3_OFFSET_CITY_PROTECTOR_TITLE`), PUBLIC like
/// it. `0` = none. Read by exactly one thing: `UnitPVPName`'s medal line, which appends
/// `"\n"` + the `PVP_MEDAL<n>` GlobalString when it is non-zero (`0x6093ef`).
///
/// **v1 feed gap, stated not hidden:** no app-side feed fills this yet — it rides the same
/// dword the rank byte is decoded from, so wiring it is one line there, and until that line
/// exists every unit reads `0` and `UnitPVPName` renders no medal. The binding leg is built
/// against the field rather than left out, so the seam is a feed line rather than a missing
/// arm (same shape as [`Self::is_connected`]'s stated gap).
pub pvp_medal: u8,
/// The unit's PvP faction group — `"Alliance"` or `"Horde"`, else `None` (`UnitFactionGroup`;
/// decision 0646 §1). App-resolved from `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s
/// group mask, with the Player bit skipped: every playable race's template carries
@@ -217,6 +238,25 @@ pub fn unit_is_grey(player_level: u32, unit_level: u32) -> bool {
player_level > unit_level && player_level - unit_level > grey_band(player_level)
}
/// The **CIVILIAN predicate `0x612550`** — "killing this would be a dishonorable kill": the unit's
/// PvP bit **and** the creature-query civilian flag **and** the unit is HOSTILE to the player
/// (`UnitReaction` ≤ 2) **and** the kill would be GREY/trivial ([`unit_is_grey`]). A friendly
/// civilian, or one that still cons, is not one.
///
/// **ONE home, two callers**, which is the whole reason it is a function: the unit tooltip's green
/// CIVILIAN line ([`super::tooltip_unit`], decision 0276) and `UnitPVPName`'s civilian arm
/// ([`super::pvp`] — `0x609370` leg B, which prefixes `PVP_RANK_CIVILIAN` onto a non-player's
/// name). The engine gates both on the same call; a second copy here would let the tooltip and the
/// name disagree about whether the same mob is a civilian.
///
/// **Scope of the verification.** wow-re's honor carve leaves `0x612550` itself uncarved (its §12,
/// "named by its use here"); the four terms are decision 0276's tooltip-line law, which is where
/// this composition was verified. So this is the tooltip's gate reused at the second call site the
/// carve proves shares it — not an independently byte-checked predicate.
pub fn is_civilian_kill(u: &UnitState, player_level: u32) -> bool {
u.civilian && u.pvp && u.reaction != 0 && u.reaction <= 2 && unit_is_grey(player_level, u.level)
}
/// The "level reads ??" gate — the engine tooltip's byte-pinned leg set (`0x529fe0` §2-LEVEL):
/// never players; level 0 (unstreamed), world-boss rank 3, or a much-higher hostile (reaction
/// ≤ 2 on the 1..8 API scale, unit ≥ player + 10 — **inclusive**, byte-confirmed). The Lua