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
+34 -26
View File
@@ -37,9 +37,10 @@
merchant-tab precedent ("tabs arrive with their pages") — `BENILLA_CHARACTERFRAME_SUBFRAMES`
only lists the pages that exist. Skills arrived with decision 0437 phase 4
(`SkillFrame.xml`), **Pet with decision 1057** (`PetPaperDollFrame.xml`), which takes the
real client's own slot 2, and **Reputation** (`ReputationFrame.xml`), which takes its own
slot 3 and puts Skills back on the reference's 4 — so the first four tabs are the
reference's, in its order, at its numbers, and only Honor (its tab 5) is still out. Three things here are shared
real client's own slot 2, **Reputation** (`ReputationFrame.xml`), which takes its own
slot 3 and puts Skills back on the reference's 4, and **Honor** (`HonorFrame.xml`) on its
own 5 — so the row is COMPLETE: all five tabs are the reference's, in its order, at its
numbers, and nothing is omitted any more. Three things here are shared
WITH the pet page rather than owned by this one, and each is flagged at its definition: the
`(unit, prefix)` stat setters (including `_SetDefense`, which exists for the pet row alone),
the `BenillaPaperDollModel_*` pane quartet (driven through each pane's own `frame.setFacing`),
@@ -242,16 +243,18 @@
-- MagicResFrame1..5's ref `id=` attrs (ref-PaperDollFrame.xml:654-759) — arcane/fire/nature/
-- frost/shadow; Holy(1) never rides the paper doll (only 5 of the 6 schools show here).
BENILLA_MAGICRES_IDS = { 6, 2, 3, 4, 5 }
-- The pages that exist so far (this file's header comment; the 0138 merchant-tab
-- precedent — tabs arrive with their pages), named exactly as the reference names them
-- ("PaperDollFrame"/"ReputationFrame"/"SkillFrame"). 1:1 with the tab row below (index N is
-- tab N's page) — decision 0437 phase 4 added the Skills page, decision 1057 the Pet one,
-- and the Reputation page (ReputationFrame.xml) takes the real client's own slot 3, which
-- puts Skills at the real client's own 4. The FIRST FOUR SLOTS ARE NOW THE REFERENCE'S:
-- only Honor (its tab 5) is still OMITTED, and it sits past the end where its absence
-- shifts nothing.
-- The window's pages, named exactly as the reference names them
-- ("PaperDollFrame"/"PetPaperDollFrame"/"ReputationFrame"/"SkillFrame"/"HonorFrame"). 1:1
-- with the tab row below (index N is tab N's page) — decision 0437 phase 4 added the Skills
-- page, decision 1057 the Pet one, ReputationFrame.xml took the real client's own slot 3
-- (which put Skills at the real client's own 4), and HonorFrame.xml takes the 5.
-- THE ROSTER IS NOW THE REFERENCE'S, WHOLE: five pages, five tabs, no omission left and
-- nothing past the end. The 0138 merchant-tab precedent ("tabs arrive with their pages") is
-- what kept this list a prefix of the real one until now, rather than a row of dead tabs.
-- `character_tests.rs` pins each page's `id=` against its slot here, which is what kept the
-- two in step as the middle of the row filled in.
BENILLA_CHARACTERFRAME_SUBFRAMES = {
"PaperDollFrame", "PetPaperDollFrame", "ReputationFrame", "SkillFrame",
"PaperDollFrame", "PetPaperDollFrame", "ReputationFrame", "SkillFrame", "HonorFrame",
}
-- ── The window: toggle / tab dispatch (ref CharacterFrame.lua:1-48) ─────────────────────
@@ -323,13 +326,14 @@
local frame = this
-- GetID()/SetID() DO exist (landed with slice 2's BagFrame, which relies on them for
-- its own per-window bag id) — the STALE claim this comment used to make is corrected
-- here (0208 phase 1b touched this function anyway). 4 tabs ship with the Reputation
-- page (Character + Pet + Reputation + Skills — the reference's own first four); the
-- here (0208 phase 1b touched this function anyway). All 5 tabs ship with the Honor
-- page (Character + Pet + Reputation + Skills + Honor — the reference's own five); the
-- window opens on tab 1 by default, matching the real client. Tab 2 raises and hides
-- itself with the pet — PetTab_Update, which the pet page runs at its own OnLoad and on
-- every pet transition — so the COUNT here is the full row, not the currently-visible
-- one, exactly as the ref's SetNumTabs(5) is.
PanelTemplates_SetNumTabs(frame, 4)
-- one, which is why this number reaches the ref's own SetNumTabs(5) rather than
-- tracking what is on screen.
PanelTemplates_SetNumTabs(frame, 5)
PanelTemplates_SetTab(frame, 1)
-- The reference LABELS its numerals here (CharacterFrame_OnLoad l.55-57: "Health" on
-- the player's health bar, "XP" on the XP bar, with the power bar's following the
@@ -382,8 +386,9 @@
-- unlike `relativeTo`, which resolves by name regardless of tree position — see
-- SkillFrame.xml's own header comment) — so nothing else would hide it when the WHOLE
-- character window closes (ESC, the close X, or another left-slot window bumping it via
-- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once,
-- keeps this correct as more non-nested tabs (Pet/Reputation/Honor) land the same way.
-- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once, is
-- what carried that correctness through every non-nested tab that followed — Pet,
-- Reputation and now Honor, all four of the five pages that are separate files.
function CharacterFrame_OnHide()
if UpdateMicroButtons then UpdateMicroButtons() end
PlaySound("igCharacterInfoClose")
@@ -1426,13 +1431,12 @@
</Scripts>
</Button>
<!-- The tab row (ref l.79-168 — CENTER of BOTTOMLEFT +60,+62 — each tab chained off the
last, MerchantFrame.xml's own Merchant/Buyback LEFT-of-RIGHT -16 offset). Four of
the real client's five, in ITS OWN ORDER and at ITS OWN NUMBERS:
Character/Pet/Reputation/Skills. The long-standing deviation flagged here — our
Skills tab rendering 3rd where the reference's is 4th, because Reputation did not
exist — is GONE with ReputationFrame.xml; only Honor (the reference's tab 5) is
still out, and it sits past the end where its absence shifts nothing (the "tabs
arrive with their pages" precedent, 0138/0208).
last, MerchantFrame.xml's own Merchant/Buyback LEFT-of-RIGHT -16 offset). ALL FIVE
of the real client's tabs, in ITS OWN ORDER and at ITS OWN NUMBERS:
Character/Pet/Reputation/Skills/Honor. Both deviations once flagged here are gone —
our Skills tab rendering 3rd where the reference's is 4th went with
ReputationFrame.xml, and the missing fifth went with HonorFrame.xml — so this row
carries no divergence from the reference at all.
Tab 2 starts HIDDEN — it is raised only for a unit with a pet UI, by the pet page's
PetTab_Update, which also re-anchors tab 3 onto tab 2's LEFT while it is
down so the row has no gap (ref PetPaperDollFrame.lua:189-198). That is now the
@@ -1441,7 +1445,7 @@
No hover tooltip (MicroButtonTooltipText/TOGGLECHARACTER0 have no engine surface,
and each tab's own visible label already says what it does — Merchant's own tab
pair carries no hover tooltip either). -->
<!-- The four labels are the ref's own GlobalStrings KEYS (l.79/97/114/…), not literals —
<!-- The five labels are the ref's own GlobalStrings KEYS (l.79/97/114/…), not literals —
the loader resolves a key-shaped `text=` through the shipped strings, so the row is
localized like the reference's. (Tabs 1/4 carried the enUS literals from 0437;
same words either way in enUS, and now the same mechanism.) -->
@@ -1461,6 +1465,10 @@
<Anchors><Anchor point="LEFT" relativeTo="CharacterFrameTab3" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(4)</OnClick></Scripts>
</Button>
<Button name="CharacterFrameTab5" inherits="CharacterFrameTabButtonTemplate" text="HONOR">
<Anchors><Anchor point="LEFT" relativeTo="CharacterFrameTab4" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(5)</OnClick></Scripts>
</Button>
<!-- The paper-doll page (ref PaperDollFrame, ref-PaperDollFrame.xml:124-1059). Sized to
exactly fill the parent instead of the ref's setAllPoints="true" — the same effect
+645
View File
@@ -0,0 +1,645 @@
<Ui>
<!--
benilla's Honor tab: our own build of the 1.12 `HonorFrame`, the fifth and last page of the
character window. The reference FrameXML `HonorFrame.{xml,lua}` + `HonorFrameTemplates.xml`
are the SPECIFICATION throughout (cites read `ref l.N` into `HonorFrame.xml`, `ref-lua l.N`
into `HonorFrame.lua`, `ref-tpl l.N` into `HonorFrameTemplates.xml`); every geometry number
and art path below is QUOTED from them, because those are the client's facts and there is no
other value they could take. The Lua is OURS, written against that specification and onto
the PvP/honor Era API (`benilla-ui/src/script/pvp.rs` and its honor siblings). Frame names
are the reference's, because a frame name is the addon-visible contract and not ours to
vary.
WHAT THE PAGE IS. A rank block at the top (badge, rank title, "(Rank N)", and the progress
bar that says how far through the rank you are), then five labelled sections of counters:
Today, Yesterday, This Week, Last Week, Lifetime.
A CHILD PAGE of `CharacterFrame` (ref l.5: `parent="CharacterFrame"`, `id="5"`), shown and
hidden by `CharacterFrame_ShowSubFrame` — but a SEPARATE top-level file, the
SkillFrame.xml / PetPaperDollFrame.xml / ReputationFrame.xml shape: this engine reads no XML
`parent=` attribute (only physical `<Frames>` nesting creates a real parent), so the page is
pinned to `CharacterFrame`'s own TOPLEFT by NAME and loaded right after its siblings so it
paints on top (later file = higher z). Joining `BENILLA_CHARACTERFRAME_SUBFRAMES` as entry 5
is all it has to do to be closed by `CharacterFrame_OnHide`'s hide-loop.
THE TAB ROW IS NOW WHOLE. Four of the reference's five tabs shipped before this page and
each sat at the reference's own number; this is the fifth, so the row and
`BENILLA_CHARACTERFRAME_SUBFRAMES` are the real client's, entire, with nothing left past
the end. The invariant CharacterFrame.xml states — a page's `id=` IS its slot in the tab
row, and the roster is the same 1:1 mapping written the other way round — is pinned by
`character_tests.rs`, whose loop now runs the full five.
NO OWN PORTRAIT: the "General" quadrant art below carries the portrait-ring cutout every
PaperDollInfoFrame page shares, so `CharacterFrame`'s portrait/name/level (drawn underneath)
shows through. ReputationFrame.xml's and SkillFrame.xml's note, unchanged.
THE TWO SHARED PAINTERS. `BenillaHonorPane_SetRows` and `BenillaHonorPane_SetRank` are
written unit-first and pane-first because the INSPECT window's honor page (InspectFrame.xml)
paints the identical twelve rows and the identical rank block from a foreign player's
numbers. The reference keeps two full copies of both, one per pane, differing only in the
frame prefix and the unit token; we keep one. The row templates below are shared the same
way — the reference shares those too (its `HonorFrameTemplates.xml` is `<Include>`d by
`HonorFrame.xml` and inherited by `Blizzard_InspectUI`), which is why this file must load
BEFORE `InspectFrame.xml` in `benilla.toc`.
DEVIATIONS (each re-cited at its own site below):
1. THE GLOBALSTRINGS ARE NAMED, NEVER COPIED. Older windows here carry an
`X = X or "<the enUS text>"` fallback so a chainless harness still paints words; this
page ships no such copy for its own labels, because three of them are the
`NEWBIE_TOOLTIP_*` paragraphs and shipping Blizzard's prose is exactly what decision
1234 forbids. The section labels reach the player's own strings through the loader's
`text=` resolution (a `text=` attribute is a GlobalStrings LOOKUP, `loader::resolve_text`);
the row labels and tooltip bodies reach them as plain globals. A harness with no
install behind it therefore paints a key where a label goes and raises if you hover a
row — the honest face of "these words live in the install, not in our tree". The ONE
exception is `PLAYER_LEVEL`, a format template rather than prose, declared here with
the same guarded fallback CharacterFrame.xml and InspectFrame.xml already carry and
for their stated reason: load order must not be a hidden dependency between windows.
2. THE NEWBIE TIPS GO THROUGH THE HOUSE HELPER. All five hover sites call
`BenillaTooltip_AddNewbieTipFor` (GameTooltip.xml), so they obey `SHOW_NEWBIE_TIPS` —
the "Show Detailed Tooltips" box our own OptionsFrame ships — like every other newbie
tip in this UI. The reference's row template does the same thing by hand (ref-tpl
l.8-16) but ignores the CVar, and its rank/bar pair (ref l.324-333, l.352-361) ignores
it AND anchors beside the widget instead of at the screen corner. Detailed tips are ON
by default (1.12's own default, GameTooltip.xml), so the default look is the
reference's; the difference is that a player who turned them off is obeyed here.
3. THE ROW TEMPLATES ARE THE REFERENCE'S FIVE, under the reference's five names, but the
base carries everything that can be shared — the strip's size and the whole hover —
and the leaves carry only the two FontStrings. They cannot carry less: this engine's
`inherits=` concatenates children instead of merging them by name
(`framexml::merge`), so a leaf that re-declared an inherited region would end up with
two of it. That constraint is stated at the template rather than discovered later.
4. THE LEVEL LINE PAINTS ITSELF. `HonorLevelText` is the reference's own FontString, and
the reference fills it from the PAPER DOLL's setter — `PaperDollFrame_SetLevel` writes
`CharacterLevelText` and then, in its own words, sets it "for the honor frame while we
at it" (ref PaperDollFrame.lua:100-104). That is a hidden page reaching across a file
boundary into another hidden page. The string belongs to THIS page, so the write does
too: this file registers `UNIT_LEVEL` itself and paints its own line.
5. `HonorGuildText` (ref l.68-76) IS DROPPED, with its `CharacterGuildText` twin — decision
0208's own named deferral, restated in CharacterFrame.xml: the guild line needs a guild
query this window has never made, and the honor page's copy is the same line.
6. THE RANK TITLE'S XML `y` IS THE ONE THE PAGE ACTUALLY WEARS. The reference anchors
`HonorFrameCurrentPVPTitle` at TOP (0,-87) and then re-points it every repaint at
(…,-83) (ref l.127-134 vs ref-lua l.76); its inspect twin anchors at -81 and re-points
at -83 the same way. The XML value is unobservable — nothing paints before the first
repaint — so the two contradictory numbers are collapsed to the one that survives.
7. `enableMouse="true"` on the StatusBar and on the two hover frames. In this engine only
Button/CheckButton/EditBox/ScrollingMessageFrame are mouse-enabled by construction
(`widget/mod.rs`; UnitFrames.xml, CharacterFrame.xml and ReputationFrame.xml all carry
the same note), so a Frame with an `<OnEnter>` needs the attribute or the handler
silently never fires. This REPRODUCES the reference's behaviour rather than departing
from it.
-->
<Script><![CDATA[
-- ── The one string this file spells out ─────────────────────────────────────────────────
-- A format TEMPLATE, not prose, and declared here rather than borrowed from
-- CharacterFrame.xml's identical line for that file's own stated reason: load order must
-- not be a hidden dependency between two windows. Everything else this page says comes out
-- of the player's own GlobalStrings.lua (this file's header, deviation 1).
PLAYER_LEVEL = PLAYER_LEVEL or "Level %d %s %s"
-- ── The row: two strings and a hover ────────────────────────────────────────────────────
-- A row remembers the pair its hover shows: its own label as the tip's title, and the
-- matching detailed explanation as the body. Both are the player's strings, passed in by
-- the instance below rather than baked into a template, so the five leaf templates differ
-- only in what they LOOK like.
function BenillaHonorRow_OnLoad(row, title, detail)
row.title = title
row.detail = detail
end
-- The house newbie tip (this file's header, deviation 2): label in highlight white, the
-- explanation wrapped under it in NORMAL_FONT_COLOR, both only when the player has detailed
-- tips on.
function BenillaHonorRow_OnEnter(row)
BenillaTooltip_AddNewbieTipFor(row, row.title,
HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, row.detail)
end
-- ── The two painters both honor panes share (ref-lua l.19-77 and its inspect twin) ───────
-- Paint a set of counters. `pane` is the owning frame's name, `values` a
-- `{ [rowSuffix] = number }` table: each row frame is `<pane><suffix>` and its figure lives
-- in that frame's own `$parentValue` string, so a repaint is a table walk rather than
-- twelve hand-written assignments. Rows are looked up by name and skipped when absent,
-- which is what lets a caller paint a SUBSET (the character pane repaints its weekly
-- sections on a different trigger from its session ones — see HonorFrame_Update).
--
-- A nil figure is likewise not painted, and that falls out of Lua rather than being coded:
-- a nil value never enters the table at all, so `pairs` cannot reach it. That is the
-- behaviour we want on the inspect side, where the whole twelve-wide read answers with
-- NOTHING until the server's reply lands — better a row that has not been filled in yet
-- than a row confidently showing a zero nobody sent.
function BenillaHonorPane_SetRows(pane, values)
for suffix, value in pairs(values) do
local figure = getglobal(pane .. suffix .. "Value")
if figure then
figure:SetText(value)
end
end
end
-- Paint the rank block: the badge, the rank's title, the "(Rank N)" that trails it, and the
-- progress bar under both. `rank` is the unit's rank as the engine reports it, `progress`
-- the 0..1 position within that rank, `unit` the token whose faction colours the bar.
--
-- Called with ("HonorFrame", …, "player") by the page below and with
-- ("BenillaInspectHonorFrame", …, the inspected token) by InspectFrame.xml.
function BenillaHonorPane_SetRank(pane, rank, progress, unit)
local rankName, rankNumber = GetPVPRankInfo(rank)
local title = getglobal(pane .. "CurrentPVPTitle")
local ordinal = getglobal(pane .. "CurrentPVPRank")
-- THE NIL IS THE MECHANISM, not an edge case. The rank names are GlobalStrings keyed
-- `PVP_RANK_<rank>_<team>` and there is no `PVP_RANK_0_*`: rank 0 means "no rank at
-- all", which is every character who has never taken an honorable kill. So the name
-- comes back nil there and the pane says NONE. The NUMBER is not guarded, and that is
-- deliberate too: it is a rank arithmetic result, never absent, and swallowing a nil
-- would hide a broken binding instead of reporting it.
title:SetText(rankName or NONE)
-- `RANK or ""` rather than a bare concat: a harness with no install behind it has no
-- GlobalStrings, and taking the pane down over a missing label helps nobody.
ordinal:SetText("(" .. (RANK or "") .. " " .. rankNumber .. ")")
-- The badge is built HERE, not by the engine: the file name is the rank number padded
-- to two digits, and it is shown only above zero. Verified against the patch chain this
-- session: `PvPRank01`..`PvPRank15` exist and `PvPRank00`/`16`/`17`/`18`/`19` do not,
-- which is exactly the range the arithmetic can produce (the fourteen player ranks plus
-- the PvP-leader one); zero and the four negative ranks have no badge to show.
local badge = getglobal(pane .. "PvPIcon")
if rankNumber > 0 then
badge:SetTexture(format("Interface\\PvPRankBadges\\PvPRank%02d", rankNumber))
badge:Show()
else
badge:Hide()
end
-- The bar wears the unit's own faction: Alliance navy, everything else Horde red. The
-- fork is a positive test for Alliance with an `else`, not a two-sided test, so a unit
-- whose faction group reads nil (a race the app has no snapshot for yet — ui_unit.rs's
-- own note) still gets a painted bar rather than a bare trough.
local bar = getglobal(pane .. "ProgressBar")
if UnitFactionGroup(unit) == "Alliance" then
bar:SetStatusBarColor(0.05, 0.15, 0.36)
else
bar:SetStatusBarColor(0.63, 0.09, 0.09)
end
bar:SetValue(progress)
-- Re-centre the PAIR, not the title. The title is centred on the pane and then shifted
-- left by half the width of the "(Rank N)" that trails it, so title and ordinal read as
-- one centred phrase. It has to be redone on every repaint because that width moves
-- with the number and with the locale.
title:SetPoint("TOP", pane, "TOP", -ordinal:GetWidth() / 2, -83)
end
-- ── The character window's honor page (ref-lua l.1-13) ───────────────────────────────────
-- The two PvP events plus world entry, and one of our own. The SPLIT between them is the
-- reference's and it is deliberate: the weekly sections (yesterday, this week, last week)
-- only move at the server's weekly maintenance, so nothing carries them mid-session and
-- re-reading them per kill would be waste. World entry is the flag that says "repaint those
-- too". `UNIT_LEVEL` is ours (this file's header, deviation 4).
function HonorFrame_OnLoad(frame)
frame:RegisterEvent("PLAYER_PVP_KILLS_CHANGED")
frame:RegisterEvent("PLAYER_PVP_RANK_CHANGED")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("UNIT_LEVEL")
end
function HonorFrame_OnEvent(event)
if event == "UNIT_LEVEL" then
if arg1 == "player" then
HonorFrame_SetLevel()
end
return
end
HonorFrame_Update(event == "PLAYER_ENTERING_WORLD")
end
-- The level/race/class line under the window's name (this file's header, deviation 4). The
-- "?" placeholders match CharacterFrame.xml's and exist for its reason: UnitRace/UnitClass
-- read nil for the one tick before the app's first player snapshot lands, and an unguarded
-- format() errors on that nil.
function HonorFrame_SetLevel()
getglobal("HonorLevelText"):SetText(
format(PLAYER_LEVEL, UnitLevel("player"), UnitRace("player") or "?", UnitClass("player") or "?"))
end
-- `weekly` repaints the three sections that only move at the weekly reset; everything else
-- is repainted on every call. Note which return each figure comes from — the four stat
-- readers do NOT have the same shape, and that is the server's doing, not a slip:
-- yesterday and last week report dishonorable kills the pane does not show, and only last
-- week carries a standing.
function HonorFrame_Update(weekly)
if weekly then
local yesterdayHK, _, yesterdayHonor = GetPVPYesterdayStats()
local thisWeekHK, thisWeekHonor = GetPVPThisWeekStats()
local lastWeekHK, _, lastWeekHonor, standing = GetPVPLastWeekStats()
BenillaHonorPane_SetRows("HonorFrame", {
YesterdayHK = yesterdayHK,
YesterdayContribution = yesterdayHonor,
ThisWeekHK = thisWeekHK,
ThisWeekContribution = thisWeekHonor,
LastWeekHK = lastWeekHK,
LastWeekContribution = lastWeekHonor,
LastWeekStanding = standing,
})
end
local sessionHK, sessionDK = GetPVPSessionStats()
local lifetimeHK, lifetimeDK, highestRank = GetPVPLifetimeStats()
-- The highest rank EVER held, which is a different field from the current one and moves
-- only upward; its name falls back to NONE on the same nil as the live rank's.
local highestName = GetPVPRankInfo(highestRank)
BenillaHonorPane_SetRows("HonorFrame", {
CurrentHK = sessionHK,
CurrentDK = sessionDK,
LifeTimeHK = lifetimeHK,
LifeTimeDK = lifetimeDK,
LifeTimeRank = highestName or NONE,
})
BenillaHonorPane_SetRank("HonorFrame", UnitPVPRank("player"), GetPVPRankProgress(), "player")
HonorFrame_SetLevel()
end
]]></Script>
<!--
The row's SHARED half (ref-tpl l.3-18): a 278x12 strip and the hover that explains it. The
five leaves below add the two FontStrings, because this engine's `inherits=` CONCATENATES a
template's children onto the inheriting node's rather than merging them by name
(`framexml::merge`) — so a leaf that re-declared `$parentText` would get TWO of them, both
drawn, sharing one name. Everything that can be hoisted is hoisted; the regions cannot be.
`enableMouse` is required for the hover to fire at all (this file's header, deviation 7).
-->
<Frame name="HonorFrameTextButtonTemplate" virtual="true" enableMouse="true">
<Size><AbsDimension x="278" y="12"/></Size>
<Scripts>
<OnEnter>BenillaHonorRow_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!--
The five leaves (ref-tpl l.19-148). Each is the same pair of strings in the same two places
and differs in exactly two things: WHICH label it carries, and what colour its figure is.
The label is the player's own GlobalStrings key, resolved by the loader (a `text=` attribute
is a lookup, not a literal); the figure's colour rides a font object rather than a colour
call, which is how every other coloured string in this UI is painted.
The value's `RIGHT` anchored to the row's own `LEFT` plus 278 is the reference's spelling of
"right-aligned at the row's right edge", quoted rather than restated as an anchor to RIGHT.
Honorable kills: GREEN.
-->
<Frame name="HonorFrameHKButtonTemplate" inherits="HonorFrameTextButtonTemplate" virtual="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentText" inherits="GameFontHighlightSmall" text="HONORABLE_KILLS" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</FontString>
<FontString name="$parentValue" inherits="GameFontGreenSmall">
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="278" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaHonorRow_OnLoad(self, HONORABLE_KILLS, NEWBIE_TOOLTIP_HONORABLE_KILLS)</OnLoad>
</Scripts>
</Frame>
<!-- Dishonorable kills: RED (ref-tpl l.45-70), because a dishonorable kill costs you rank —
the colour is the pane telling you which way the number moves you. -->
<Frame name="HonorFrameDKButtonTemplate" inherits="HonorFrameTextButtonTemplate" virtual="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentText" inherits="GameFontHighlightSmall" text="DISHONORABLE_KILLS" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</FontString>
<FontString name="$parentValue" inherits="GameFontRedSmall">
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="278" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaHonorRow_OnLoad(self, DISHONORABLE_KILLS, NEWBIE_TOOLTIP_DISHONORABLE_KILLS)</OnLoad>
</Scripts>
</Frame>
<!-- Honor earned in the period (ref-tpl l.71-96). The key is HONOR_CONTRIBUTION_POINTS and the
string it holds in 1.12 is the single word "Honor" — the key is the older name for the same
figure, kept because it is the key the player's own string table actually has. -->
<Frame name="HonorFrameContributionButtonTemplate" inherits="HonorFrameTextButtonTemplate" virtual="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentText" inherits="GameFontHighlightSmall" text="HONOR_CONTRIBUTION_POINTS" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</FontString>
<FontString name="$parentValue" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="278" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaHonorRow_OnLoad(self, HONOR_CONTRIBUTION_POINTS, NEWBIE_TOOLTIP_HONOR_CONTRIBUTION_POINTS)</OnLoad>
</Scripts>
</Frame>
<!-- Last week's standing (ref-tpl l.97-122): where you placed against everyone on your own
side. Only the Last Week section has one — it is a ranking of a finished week. -->
<Frame name="HonorFrameStandingButtonTemplate" inherits="HonorFrameTextButtonTemplate" virtual="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentText" inherits="GameFontHighlightSmall" text="HONOR_STANDING" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</FontString>
<FontString name="$parentValue" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="278" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaHonorRow_OnLoad(self, HONOR_STANDING, NEWBIE_TOOLTIP_HONOR_STANDING)</OnLoad>
</Scripts>
</Frame>
<!-- The highest rank ever held (ref-tpl l.123-148). Its label and its TIP TITLE deliberately
differ, and that is the reference's own choice, not a slip: the row is labelled "Highest
Rank" because that is what the figure is, while the hover is titled "Rank" because the
paragraph under it explains ranking in general. -->
<Frame name="HonorFrameRankButtonTemplate" inherits="HonorFrameTextButtonTemplate" virtual="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentText" inherits="GameFontHighlightSmall" text="HONOR_HIGHEST_RANK" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</FontString>
<FontString name="$parentValue" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="278" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaHonorRow_OnLoad(self, RANK, NEWBIE_TOOLTIP_RANK)</OnLoad>
</Scripts>
</Frame>
<!-- id=5, the reference's own (ref l.5): a page's id is the slot it occupies in the WINDOW'S
OWN tab row, and ToggleCharacter selects the tab from it. `character_tests.rs` pins every
page's id against its slot in BENILLA_CHARACTERFRAME_SUBFRAMES, and with this page the two
cover the real client's whole row. -->
<Frame name="HonorFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="5">
<!-- The real 384x512 page. The reference's `setAllPoints="true"` needs the true parenting
this engine's cross-file `parent=` does not give (this file's header comment), so the
page is sized and pinned to CharacterFrame's TOPLEFT by name — ReputationFrame.xml,
SkillFrame.xml and PetPaperDollFrame.xml all ship the identical substitute. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<!-- The four quadrant slabs (ref l.10-58), at the reference's own four TOPLEFT
offsets. The "General" set is the variant every PaperDollInfoFrame page shares
and carries the portrait-ring cutout (this file's header comment). -->
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-BottomLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-BottomRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<!-- The level/race/class line, under the window's own name (ref l.59-67). This page
is the only sibling of the paper doll that carries one; the guild line beside
it in the reference is dropped with its twin (header deviation 5). The
reference ships a "Level level race class" placeholder in the attribute, which
only shows on a page that has not been painted — and this one is painted before
it can be seen, so the placeholder is dropped (the ReputationFrame.xml
precedent for the reference's own dead placeholders). -->
<FontString name="HonorLevelText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="TOP" relativeTo="CharacterNameText" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<Layer level="ARTWORK">
<!-- The pane's OWN art, over the shared background: the rank plaque and the five
section plates (ref l.79-126). Four slabs again, but not the same four sizes —
the bottom pair is 128 tall, not 256. -->
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-69"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="275" y="-69"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-BottomLeft">
<Size><AbsDimension x="256" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-325"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-BottomRight">
<Size><AbsDimension x="128" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="275" y="-325"/></Offset></Anchor></Anchors>
</Texture>
<!-- The rank title and the "(Rank N)" beside it (ref l.127-144). The title's y is
the repaint's, not the XML's (header deviation 6); the pair is re-centred
together on every repaint by BenillaHonorPane_SetRank. -->
<FontString name="HonorFrameCurrentPVPTitle" inherits="GameFontNormal">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-83"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="HonorFrameCurrentPVPRank" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="LEFT" relativeTo="HonorFrameCurrentPVPTitle" relativePoint="RIGHT"><Offset><AbsDimension x="5" y="-1"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- The five section headings (ref l.145-189), each chained off the last. The gaps
are NOT uniform (41/43/42/64) because each section holds a different number of
rows; the 64 before Lifetime is the three-row Last Week section above it. Each
`text=` is the player's own GlobalStrings key, resolved by the loader — note
the first one reads "Today" in 1.12, not "This Session". -->
<FontString name="HonorFrameCurrentSessionTitle" inherits="GameFontNormal" text="HONOR_THIS_SESSION">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="36" y="-112"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="HonorFrameYesterdayTitle" inherits="GameFontNormal" text="HONOR_YESTERDAY">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameCurrentSessionTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-41"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="HonorFrameThisWeekTitle" inherits="GameFontNormal" text="HONOR_THISWEEK">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameYesterdayTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-43"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="HonorFrameLastWeekTitle" inherits="GameFontNormal" text="HONOR_LASTWEEK">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameThisWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-42"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="HonorFrameLifeTimeTitle" inherits="GameFontNormal" text="HONOR_LIFETIME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLastWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-64"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<Layer level="OVERLAY">
<!-- The rank badge, left of the title (ref l.191-204). No `file=` here: the path is
built per rank at repaint time (BenillaHonorPane_SetRank), and there is no
badge at all below rank 1 — an unpainted texture is an empty one. -->
<Texture name="HonorFramePvPIcon">
<Size><AbsDimension x="24" y="24"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="HonorFrameCurrentPVPTitle" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- THE TWELVE ROWS (ref l.207-314), section by section. Each section's first row
hangs off its heading's BOTTOMLEFT indented 10, and each following row chains off
the one above it. The row SET per section is the shape of the data, not a layout
choice: Today is the only section that shows dishonorable kills beside honorable
ones without an honor figure (honor for today is not banked yet), the two middle
sections trade the dishonorable count for the honor earned, Last Week adds the
standing that closed it, and Lifetime trades honor for the highest rank ever held.
Today. -->
<Frame name="HonorFrameCurrentHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameCurrentSessionTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameCurrentDK" inherits="HonorFrameDKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameCurrentHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- Yesterday. -->
<Frame name="HonorFrameYesterdayHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameYesterdayTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameYesterdayContribution" inherits="HonorFrameContributionButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameYesterdayHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- This Week. -->
<Frame name="HonorFrameThisWeekHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameThisWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameThisWeekContribution" inherits="HonorFrameContributionButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameThisWeekHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- Last Week: the only section with three rows, and the only one with a standing. -->
<Frame name="HonorFrameLastWeekHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLastWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameLastWeekContribution" inherits="HonorFrameContributionButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLastWeekHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameLastWeekStanding" inherits="HonorFrameStandingButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLastWeekContribution" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- Lifetime. -->
<Frame name="HonorFrameLifeTimeHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLifeTimeTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-4"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameLifeTimeDK" inherits="HonorFrameDKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLifeTimeHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="HonorFrameLifeTimeRank" inherits="HonorFrameRankButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="HonorFrameLifeTimeDK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- The rank hover (ref l.315-334): an invisible strip spanning badge to ordinal, so
the whole "badge Marshal (Rank 10)" phrase explains itself. Two anchors and no
width of its own — the LEFT edge is the badge's, the RIGHT edge the ordinal's, so
the strip re-fits itself when either moves. -->
<Frame name="HonorFrameRankButton" enableMouse="true">
<Size><AbsDimension x="10" y="24"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="HonorFramePvPIcon"/>
<Anchor point="RIGHT" relativeTo="HonorFrameCurrentPVPRank"/>
</Anchors>
<Scripts>
<OnEnter>BenillaTooltip_AddNewbieTipFor(self, RANK, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- The progress bar (ref l.335-370): how far through the current rank you are, 0..1.
It reuses the skills bar's own fill art, which is the reference's choice.
The two frame-level drops are the reference's and they are the mechanism, not
decoration. The bar's fill is a BORDER-layer texture on a child frame, and the
pane's plaque art is ARTWORK on the pane itself: at the child's natural level the
fill would sort above the plaque and paint over the socket cut for it. Dropping the
bar to the pane's own level puts the two in one bucket, where the draw layer
decides and ARTWORK wins. The hover frame inside it drops again for the same
reason, and because it carries no art its level only decides where a mouse lands. -->
<StatusBar name="HonorFrameProgressBar" enableMouse="true" drawLayer="BORDER" minValue="0" maxValue="1" defaultValue="0">
<Size><AbsDimension x="315" y="29"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-77"/></Offset></Anchor></Anchors>
<Frames>
<Frame name="HonorFrameProgressButton" enableMouse="true" setAllPoints="true">
<Scripts>
<OnLoad>self:SetFrameLevel(self:GetFrameLevel() - 1)</OnLoad>
<OnEnter>BenillaTooltip_AddNewbieTipFor(self, RANK_POSITION, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK_POSITION)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
</Frames>
<Scripts>
<OnLoad>self:SetFrameLevel(self:GetFrameLevel() - 1)</OnLoad>
</Scripts>
<BarTexture name="$parentBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
</StatusBar>
</Frames>
<Scripts>
<OnLoad>HonorFrame_OnLoad(self)</OnLoad>
<OnEvent>HonorFrame_OnEvent(event)</OnEvent>
</Scripts>
</Frame>
</Ui>
+341 -21
View File
@@ -30,11 +30,17 @@
`CanInspect`/`NotifyInspect`/`ClearInspectPlayer`), the same posture `ToggleCharacter`
takes.
THE HONOR TAB LANDED (ref tab 2, `Blizzard_InspectUI/InspectHonorFrame.{xml,lua}`, extracted
to session scratch alongside the rest of this window's reference). It was 0631's first named
deferral — it needed `MSG_INSPECT_HONOR_STATS` and an honor model that did not exist, and
the OWN character window had no Honor tab either — and the honor arc supplied all three at
once. The page is `BenillaInspectHonorFrame`, this window's own prefix convention; its five
row templates and both of its painters come from `HonorFrame.xml`, which is exactly the
sharing the reference does (its `HonorFrameTemplates.xml` is inherited straight into
`Blizzard_InspectUI`) and why that file must load first — see this window's `benilla.toc`
entry.
Named deferrals (decision 0631; each re-cited at its site):
- The HONOR tab (`InspectHonorFrame`, ref tab 2) — needs `MSG_INSPECT_HONOR_STATS` plus an
entire honor/PvP model that does not exist here, and the OWN character window's Honor
tab isn't built either. The tab row ships ONE entry, the 0138/0208 "tabs arrive with
their pages" precedent.
- `InspectTitleText` / `InspectGuildText` — both `hidden="true"` in the ref itself, filled
only from PVP-rank / guild-query data (0208's own two named gaps). Not transcribed.
- Ctrl-click `DressUpItemLink` and shift-click chat-link insert (ref
@@ -56,9 +62,13 @@
-- "string expected, got nil" when the window was driven without CharacterFrame loaded).
PLAYER_LEVEL = PLAYER_LEVEL or "Level %d %s %s" -- :3075
-- The subframe roster (ref INSPECTFRAME_SUBFRAMES, l.2). One page ships; the Honor tab is a
-- named deferral (header), and this list is what keeps the tab machinery honest as it lands.
BENILLA_INSPECTFRAME_SUBFRAMES = { "BenillaInspectPaperDollFrame" }
-- The subframe roster (ref INSPECTFRAME_SUBFRAMES, l.2), now the reference's own two. Index
-- N is tab N's page, and each page's `id=` is the same mapping written the other way round
-- — the invariant CharacterFrame.xml states for its own row, and the reason `ToggleInspect`
-- below can select the tab from the page rather than being told which one it is.
BENILLA_INSPECTFRAME_SUBFRAMES = {
"BenillaInspectPaperDollFrame", "BenillaInspectHonorFrame",
}
-- The 19 inspect slots, in the ref's own `InspectPaperDollFrame_OnShow` order (l.60-78).
-- Note there is NO ammo slot and NO bag buttons: a foreign player exposes neither.
@@ -98,7 +108,7 @@
frame:RegisterEvent("UNIT_NAME_UPDATE")
frame:RegisterEvent("UNIT_MODEL_CHANGED")
frame.unit = nil
PanelTemplates_SetNumTabs(frame, 1)
PanelTemplates_SetNumTabs(frame, 2)
PanelTemplates_SetTab(frame, 1)
end
@@ -136,25 +146,35 @@
-- ref InspectFrame_OnHide (l.53-59): drop the token, play the close sound, and tell the
-- engine to stop inspecting (which stops the app's per-frame slot resolve). No subframe
-- hide-loop is needed (unlike CharacterFrame_OnHide's): the one page is a true
-- engine child of this frame, so it hides on the parent's cascade.
-- hide-loop is needed (unlike CharacterFrame_OnHide's): BOTH pages are true engine children
-- of this frame — they are declared inside its own `<Frames>`, not as separate top-level
-- files the way the character window's four are — so they hide on the parent's cascade.
-- Dropping the token here is also what makes the honor page's nil check load-bearing: an
-- `MSG_INSPECT_HONOR_STATS` reply can outlive the window that asked for it.
function BenillaInspectFrame_OnHide(frame)
frame.unit = nil
PlaySound("igCharacterInfoClose")
ClearInspectPlayer()
end
-- ref ToggleInspect (l.67-83) + InspectFrame_ShowSubFrame (l.85-93). With one page shipped
-- the interesting branch is the ref's own "clicking the tab of the page already showing
-- CLOSES the window" — kept rather than smoothed away, because that is what the real
-- tab does. ToggleInspect is BARE (not Benilla-prefixed), the addon-facing name, matching
-- this codebase's ToggleCharacter/ToggleQuestLog precedent.
-- ref ToggleInspect (l.67-83) + InspectFrame_ShowSubFrame (l.85-93). The ref's own "clicking
-- the tab of the page already showing CLOSES the window" is kept rather than smoothed away,
-- because that is what the real tab does. ToggleInspect is BARE (not Benilla-prefixed), the
-- addon-facing name, matching this codebase's ToggleCharacter/ToggleQuestLog precedent.
--
-- THE TAB ROW IS SELECTED HERE, from the page's own id, on every path — the line
-- CharacterFrame.xml's `ToggleCharacter` carries and the defect it records (director,
-- 2026-08-06: a page switch that did not move the row left the old tab enabled, and
-- clicking it took the "already visible" arm and shut the window). This window could not
-- show that defect while it had one tab; it can now, so the line lands with the second one
-- rather than after the report.
function ToggleInspect(tab)
local subFrame = getglobal(tab)
if not subFrame then
return
end
local frame = getglobal("BenillaInspectFrame")
PanelTemplates_SetTab(frame, subFrame:GetID())
if frame:IsVisible() then
if subFrame:IsVisible() then
HideUIPanel(frame)
@@ -345,6 +365,76 @@
BenillaInspectModel_SetFacing(frame.facing)
end
end
-- ── The honor page (ref Blizzard_InspectUI/InspectHonorFrame.lua) ───────────────────────
--
-- WHY THIS PAGE WAITS WHERE THE PAPER DOLL DOES NOT. The gear page needs no round trip at
-- all — worn items ride PUBLIC descriptor fields the server already streams (this file's
-- header). A player's kill counters do not: they are private fields on their own object,
-- which we never see, so they come over a request and a reply of their own
-- (`MSG_INSPECT_HONOR_STATS`). Their RANK is the exception and rides the public bytes like
-- the gear does, which is why the rank block can paint from the unit while the counters
-- wait.
function BenillaInspectHonorFrame_OnLoad(frame)
frame:RegisterEvent("INSPECT_HONOR_UPDATE")
end
function BenillaInspectHonorFrame_OnEvent()
if event == "INSPECT_HONOR_UPDATE" then
BenillaInspectHonorFrame_Update()
end
end
-- ref InspectHonorFrame_OnShow: ask only when this player's numbers are not already held,
-- and repaint from them when they are. Both arms matter — the request arm is the first
-- visit, the repaint arm is every visit after it, and skipping the request when the answer
-- is already in hand is what keeps flipping between the two tabs from re-asking the server.
function BenillaInspectHonorFrame_OnShow()
if HasInspectHonorData() then
BenillaInspectHonorFrame_Update()
else
RequestInspectHonorData()
end
end
function BenillaInspectHonorFrame_Update()
-- THE TOKEN IS THIS WINDOW'S OWN, not the reference's hardcoded "target". The ref reads
-- `UnitPVPRank("target")`/`UnitFactionGroup("target")` while its own paper doll reads
-- `this.unit`, so inspecting a PARTY member paints that party member's gear under
-- whoever you happen to be targeting's rank and faction colour. This window already
-- tracks the token it was opened on (`BenillaInspectFrame_Show` stores it; every other
-- read in this file goes through it), so the honor page reads it too and the two halves
-- of one window agree about whose player they are showing.
local unit = getglobal("BenillaInspectFrame").unit
-- The token is nil'd when the window closes, and the reply is asynchronous: closing
-- before it lands is an ordinary thing to do. The reference cannot notice, because
-- "target" is still a unit; ours would paint a rank block for nobody, so it doesn't.
if not unit then
return
end
-- Twelve figures in one call, in the order the reply's own body carries them.
local sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisWeekHK, thisWeekHonor,
lastWeekHK, lastWeekHonor, lastWeekStanding, lifetimeHK, lifetimeDK, lifetimeRank =
GetInspectHonorData()
-- The same twelve rows the character window's page paints, through the same painter
-- (HonorFrame.xml) — one section per line, in the order they read down the page.
BenillaHonorPane_SetRows("BenillaInspectHonorFrame", {
CurrentHK = sessionHK, CurrentDK = sessionDK,
YesterdayHK = yesterdayHK, YesterdayContribution = yesterdayHonor,
ThisWeekHK = thisWeekHK, ThisWeekContribution = thisWeekHonor,
LastWeekHK = lastWeekHK, LastWeekContribution = lastWeekHonor,
LastWeekStanding = lastWeekStanding,
LifeTimeHK = lifetimeHK, LifeTimeDK = lifetimeDK,
LifeTimeRank = GetPVPRankInfo(lifetimeRank) or NONE,
})
-- The rank block, off the unit for the rank and the reply for the bar. The two do come
-- from different places and that is not an inconsistency: the rank is public unit state
-- we can read at any time, the bar position is a private byte that only the reply
-- carries.
BenillaHonorPane_SetRank("BenillaInspectHonorFrame", UnitPVPRank(unit),
GetInspectPVPRankProgress(), unit)
end
]]></Script>
<!--
@@ -425,19 +515,29 @@
<OnClick>HideUIPanel(getglobal("BenillaInspectFrame"))</OnClick>
</Scripts>
</Button>
<!-- The tab row (ref l.79-99 — CENTER of BOTTOMLEFT +60,+61). One tab ships; HONOR
(ref l.100-120) is a named deferral (header). The ref's tabs carry a hover tooltip
(CHARACTER_INFO / HONOR); ours follows CharacterFrame.xml's own tab row, which
carries none — the visible label already says what it does. -->
<!-- The tab row (ref l.79-120 — CENTER of BOTTOMLEFT +60,+61, the second chained off
the first's RIGHT by -16, CharacterFrame.xml's own row spacing). BOTH of the ref's
tabs now. Its tabs carry a hover tooltip (CHARACTER_INFO / HONOR); ours follows
CharacterFrame.xml's own tab row, which carries none — the visible label already
says what it does. Tab 2's label is the GlobalStrings KEY, so it localizes with the
player's install; tab 1's enUS literal predates that convention and is left where
it is rather than swept up in an honor change. -->
<Button name="BenillaInspectFrameTab1" inherits="CharacterFrameTabButtonTemplate" text="Character">
<Anchors><Anchor point="CENTER" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="60" y="61"/></Offset></Anchor></Anchors>
<Scripts><OnClick>ToggleInspect("BenillaInspectPaperDollFrame")</OnClick></Scripts>
</Button>
<Button name="BenillaInspectFrameTab2" inherits="CharacterFrameTabButtonTemplate" text="HONOR">
<Anchors><Anchor point="LEFT" relativeTo="BenillaInspectFrameTab1" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>ToggleInspect("BenillaInspectHonorFrame")</OnClick></Scripts>
</Button>
<!-- The paper-doll page (ref InspectPaperDollFrame, l.47-371). The ref uses
setAllPoints="true"; this is sized to fill the parent instead — equivalent, and the
shape CharacterFrame.xml's own paper doll takes (its header has the history). -->
<Frame name="BenillaInspectPaperDollFrame">
shape CharacterFrame.xml's own paper doll takes (its header has the history).
id=1 is the ref's own, and it is what `ToggleInspect` selects the tab from: a
page's id IS its slot in this window's tab row (the roster's own note above). It
was absent while one page shipped, because a row of one cannot disagree. -->
<Frame name="BenillaInspectPaperDollFrame" id="1">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
@@ -611,6 +711,226 @@
<OnShow>BenillaInspectPaperDollFrame_OnShow()</OnShow>
</Scripts>
</Frame>
<!--
The honor page (ref InspectHonorFrame, its own file's l.4-364), id=2. Geometry for
geometry it is the character window's Honor tab with the level line taken out and
the frame names re-prefixed — the reference ships the two as near-copies of each
other, and every number below is quoted from the inspect one. What it is NOT a copy
of is the LOGIC: the row templates and both painters are HonorFrame.xml's, called
from this file's `BenillaInspectHonorFrame_Update` above.
A true engine child of this window (declared inside its `<Frames>`), unlike the
character window's pages, which are separate top-level files pinned by name. That is
why it needs no `parent=` substitute and why the parent's hide cascade closes it.
-->
<Frame name="BenillaInspectHonorFrame" enableMouse="true" hidden="true" id="2">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<!-- The shared "General" quadrants every PaperDollInfoFrame page wears,
carrying the portrait-ring cutout the window's own portrait shows
through (ref l.10-57). -->
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-BottomLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-BottomRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-257"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The honor plaque and the five section plates (ref l.60-107). -->
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-69"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="275" y="-69"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-BottomLeft">
<Size><AbsDimension x="256" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-325"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Honor-BottomRight">
<Size><AbsDimension x="128" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="275" y="-325"/></Offset></Anchor></Anchors>
</Texture>
<!-- The rank title and its "(Rank N)". The title's y is the one the repaint
writes, not the reference's XML -81 that nothing ever sees —
HonorFrame.xml's header states the collapse and its reason. -->
<FontString name="BenillaInspectHonorFrameCurrentPVPTitle" inherits="GameFontNormal">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-83"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaInspectHonorFrameCurrentPVPRank" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="LEFT" relativeTo="BenillaInspectHonorFrameCurrentPVPTitle" relativePoint="RIGHT"><Offset><AbsDimension x="5" y="-1"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- The five section headings (ref l.126-170), each chained off the last;
the uneven gaps are each section's own row count. -->
<FontString name="BenillaInspectHonorFrameCurrentSessionTitle" inherits="GameFontNormal" text="HONOR_THIS_SESSION">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="36" y="-112"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaInspectHonorFrameYesterdayTitle" inherits="GameFontNormal" text="HONOR_YESTERDAY">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameCurrentSessionTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-41"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="BenillaInspectHonorFrameThisWeekTitle" inherits="GameFontNormal" text="HONOR_THISWEEK">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameYesterdayTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-43"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="BenillaInspectHonorFrameLastWeekTitle" inherits="GameFontNormal" text="HONOR_LASTWEEK">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameThisWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-42"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="BenillaInspectHonorFrameLifeTimeTitle" inherits="GameFontNormal" text="HONOR_LIFETIME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLastWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-64"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<Layer level="OVERLAY">
<!-- The rank badge (ref l.172-185). Pathless: the file is chosen per rank at
repaint time and there is none below rank 1. -->
<Texture name="BenillaInspectHonorFramePvPIcon">
<Size><AbsDimension x="24" y="24"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaInspectHonorFrameCurrentPVPTitle" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The twelve rows (ref l.188-295), on HonorFrame.xml's five templates. Same
sections and the same row sets as the character window's page, because they
are the same figures about a different player. Today. -->
<Frame name="BenillaInspectHonorFrameCurrentHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameCurrentSessionTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameCurrentDK" inherits="HonorFrameDKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameCurrentHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- Yesterday. -->
<Frame name="BenillaInspectHonorFrameYesterdayHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameYesterdayTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameYesterdayContribution" inherits="HonorFrameContributionButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameYesterdayHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- This Week. -->
<Frame name="BenillaInspectHonorFrameThisWeekHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameThisWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameThisWeekContribution" inherits="HonorFrameContributionButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameThisWeekHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- Last Week, the only three-row section. -->
<Frame name="BenillaInspectHonorFrameLastWeekHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLastWeekTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-3"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameLastWeekContribution" inherits="HonorFrameContributionButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLastWeekHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameLastWeekStanding" inherits="HonorFrameStandingButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLastWeekContribution" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- Lifetime. -->
<Frame name="BenillaInspectHonorFrameLifeTimeHK" inherits="HonorFrameHKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLifeTimeTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="10" y="-4"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameLifeTimeDK" inherits="HonorFrameDKButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLifeTimeHK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaInspectHonorFrameLifeTimeRank" inherits="HonorFrameRankButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaInspectHonorFrameLifeTimeDK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- The rank hover: an invisible strip spanning badge to ordinal (ref
l.296-315), so the whole phrase explains itself. -->
<Frame name="BenillaInspectHonorFrameRankButton" enableMouse="true">
<Size><AbsDimension x="10" y="24"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="BenillaInspectHonorFramePvPIcon"/>
<Anchor point="RIGHT" relativeTo="BenillaInspectHonorFrameCurrentPVPRank"/>
</Anchors>
<Scripts>
<OnEnter>BenillaTooltip_AddNewbieTipFor(self, RANK, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- The progress bar (ref l.316-351). The two frame-level drops are the
reference's own and the mechanism is the same one HonorFrame.xml's twin
documents: they put the BORDER-layer fill in the pane's own bucket so the
pane's ARTWORK plaque draws over it rather than under. -->
<StatusBar name="BenillaInspectHonorFrameProgressBar" enableMouse="true" drawLayer="BORDER" minValue="0" maxValue="1" defaultValue="0">
<Size><AbsDimension x="315" y="29"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-77"/></Offset></Anchor></Anchors>
<Frames>
<Frame name="BenillaInspectHonorFrameProgressButton" enableMouse="true" setAllPoints="true">
<Scripts>
<OnLoad>self:SetFrameLevel(self:GetFrameLevel() - 1)</OnLoad>
<OnEnter>BenillaTooltip_AddNewbieTipFor(self, RANK_POSITION, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK_POSITION)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
</Frames>
<Scripts>
<OnLoad>self:SetFrameLevel(self:GetFrameLevel() - 1)</OnLoad>
</Scripts>
<BarTexture name="$parentBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
</StatusBar>
</Frames>
<Scripts>
<OnLoad>BenillaInspectHonorFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaInspectHonorFrame_OnEvent()</OnEvent>
<OnShow>BenillaInspectHonorFrame_OnShow()</OnShow>
</Scripts>
</Frame>
</Frames>
<Scripts>
<OnLoad>BenillaInspectFrame_OnLoad(self)</OnLoad>
+20 -3
View File
@@ -251,14 +251,31 @@ ReputationFrame.xml
# `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
# "CharacterFrame">` instead). Loaded after CharacterFrame.xml so it paints ON TOP of
# it (later file = higher z) and so `CharacterFrame` already exists for that anchor to
# resolve against — and last of the three sibling pages, because it is the last of the
# three tabs (the real client's 4).
# resolve against — seated at the real client's own tab 4, between Reputation above and
# Honor below.
SkillFrame.xml
# The Honor tab: the same separate-top-level-file shape as the three pages above it, and LAST of
# them because it is the last of the tabs (the real client's 5, ref HonorFrame.xml:5) — which
# CLOSES the row: the character window's five tabs are now the reference's five, in its order, at
# its numbers, with nothing left past the end. Its hard dependencies are all far above:
# CharacterFrame.xml (the anchor target it is pinned to by name, the tab row it raises tab 5 of,
# and the `CharacterNameText` its level line hangs under), GameTooltip.xml
# (`BenillaTooltip_AddNewbieTipFor`, which all five of its hovers go through) and Fonts.xml
# (GameFontGreenSmall/GameFontRedSmall carry the honorable/dishonorable colouring, and
# HIGHLIGHT_FONT_COLOR the tip titles).
# It must come BEFORE InspectFrame.xml, and that order is a REQUIREMENT rather than tidiness: the
# inspect window's own honor page inherits the five `HonorFrame*ButtonTemplate` row templates
# declared here, and `inherits=` resolves at LOAD. The reference has the identical edge — its
# `HonorFrameTemplates.xml` is `<Include>`d by `HonorFrame.xml` and inherited by the LoadOnDemand
# `Blizzard_InspectUI`, which loads later still.
HonorFrame.xml
# The inspect window (decision 0631): another player's paper doll. Beside the character
# window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
# `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
# itself (its slot template, handlers, and booth slot are all its own — the two windows
# share only the reference's art).
# share only the reference's art). ONE cross-window dependency does bind, and it is on the
# entry directly above: this window's honor page inherits HonorFrame.xml's five row templates
# and calls its two shared painters, the same sharing the reference does.
InspectFrame.xml
# The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the
# windows whose slots feed it (the character window above, the bags before that) and before
+10 -2
View File
@@ -694,8 +694,8 @@ use blood::{blood_spurts, load_blood_tables};
use env_damage::{hard_landing_dust, load_env_damage_table};
pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
use spell_visual::{
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, arm_mount_poof_fx, load_spell_visuals,
route_cast_visuals,
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, arm_morph_latch, arm_mount_poof_fx,
load_spell_visuals, replay_morph_kit, route_cast_visuals, MorphLatch,
};
// The aura-slot watcher is the shared trigger for BOTH halves of a state kit, so the CharProc half's
// own tests (`crate::aura_visual`) drive it directly rather than re-deriving the slot diff.
@@ -1008,6 +1008,8 @@ impl Plugin for CreatureAnimPlugin {
// drained by `crate::aura_visual`, which owns the body's alpha/tint for an aura's life.
.add_message::<crate::aura_visual::AuraProc>()
.add_message::<HardLanding>()
// The pending-morph latch — the reference's per-unit `[+0xd54]` SpellRec slot.
.init_resource::<MorphLatch>()
.add_systems(
Startup,
(
@@ -1032,6 +1034,12 @@ impl Plugin for CreatureAnimPlugin {
arm_mount_poof_fx,
// The aura-slot watcher: state kits persist for the aura's life (the bread).
arm_aura_state_fx,
// The pending-morph latch (wow-re `shapeshift-morph-cloud.md`): the aura
// add/remove edges arm it, and a display swap's rebuild replays the latched
// spell's impact kit — the shapeshift cloud, both directions. Arm before
// replay: the swap message crosses from last frame's teardown, and its
// latch was armed by the same wire burst.
(arm_morph_latch, replay_morph_kit).chain(),
blood_spurts,
// The landing predictor's dust leg (the vocal leg lives in `sound`).
hard_landing_dust,
@@ -1334,3 +1334,138 @@ pub(super) fn arm_mount_poof_fx(
// Streamed units despawn on range-out — drop their mount memory with them.
displays.retain(|e, _| units.contains(*e));
}
/// The one-slot **pending-morph latch** — the reference's `[unit+0xd54]`, a `SpellRec*` (wow-re
/// `shapeshift-morph-cloud.md`, §5 trio 2026-08-21): armed by `0x5ff0c0` on every aura **add
/// AND remove** whose spell passes [`is_morph_spell`]; consumed and cleared by the DISPLAYID
/// rebuild `0x60abe0`, which — after draining every attached effect and rebuilding the model —
/// **replays the latched spell's IMPACT kit** (stage 1, `0x6ec1e0` → `[SpellVisual+0xc]` →
/// `0x60edf0` at `0x60ad67`, cleared at `0x60ad6c`). That replay IS the druid-morph cloud the
/// player sees, both directions: shift-in's SPELL_GO impact instance dies in the drain and the
/// replay restores it in sync with the swap; shift-out has no GO at all (the cancel is
/// client-side, `shapeshift-plaincast-toggle.md`) — the aura-REMOVE handler's last call
/// (`0x6123ad`) re-arms the latch, and the aura fields precede DISPLAYID in the applier's
/// ascending field order, so the demorph revert replays the same kit. A swap with no latch — a
/// GM morph, a revive — plays nothing. One entry per unit (a one-slot field); entries die with
/// the unit ([`arm_morph_latch`]'s sweep).
#[derive(Resource, Default)]
pub(crate) struct MorphLatch(EntityHashMap<u32>);
/// The latch-arm predicate `0x5ff100`: any effect slot with `Effect == 6` (APPLY_AURA) whose
/// `EffectApplyAuraName` is 36 (MOD_SHAPESHIFT) or 56 (TRANSFORM) — slot-paired, and NO other
/// gate (no class/positive/unit-type test anywhere in `0x5ff0c0`).
fn is_morph_spell(spells: &crate::ui_action::Spells, spell_id: u32) -> bool {
const SPELL_EFFECT_APPLY_AURA: u32 = 6;
const SPELL_AURA_MOD_SHAPESHIFT: u32 = 36;
const SPELL_AURA_TRANSFORM: u32 = 56;
spells.catalog.get(spell_id).is_some_and(|d| {
d.effects.iter().zip(&d.effect_apply_aura).any(|(&e, &a)| {
e == SPELL_EFFECT_APPLY_AURA
&& (a == SPELL_AURA_MOD_SHAPESHIFT || a == SPELL_AURA_TRANSFORM)
})
})
}
/// Arm [`MorphLatch`] from the aura-slot diff — the reference's TWO arm sites folded into the
/// one place benilla sees both edges: the aura-add watcher and the aura-remove handler's tail
/// (`0x6123ad`) both call `0x5ff0c0(spellId)`, and a later arm overwrites an earlier one (one
/// slot). First sight of a unit seeds the diff baseline silently — the reference's watchers
/// fire on VALUES deltas, never on create, so a druid streaming in mid-form must not latch.
/// Keyed to its own full-slot diff rather than [`arm_aura_state_fx`]'s `armed` map, which
/// tracks only state-kit spells — the form spells carry none.
pub(super) fn arm_morph_latch(
units: Query<(Entity, &ObjectStore)>,
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut latch: ResMut<MorphLatch>,
mut seen: Local<EntityHashMap<Vec<u32>>>,
) {
for (entity, store) in &changed {
let mut cur: Vec<u32> = store.0.unit_auras().map(|a| a.spell_id).collect();
cur.sort_unstable();
cur.dedup();
match seen.entry(entity) {
bevy::platform::collections::hash_map::Entry::Vacant(e) => {
e.insert(cur); // first sight arms silently
}
bevy::platform::collections::hash_map::Entry::Occupied(mut e) => {
let prev = e.get_mut();
if *prev == cur {
continue; // some other field moved
}
if let Some(spells) = spells.as_deref() {
// Remove edges first, add edges second: a form→form swap latches the form
// being ENTERED (both resolve to the same cloud family regardless).
let removed = prev.iter().filter(|s| cur.binary_search(s).is_err());
let added = cur.iter().filter(|s| prev.binary_search(s).is_err());
for &spell_id in removed.chain(added) {
if is_morph_spell(spells, spell_id) {
debug!("anim: morph latch armed ({entity}, spell {spell_id})");
latch.0.insert(entity, spell_id);
}
}
}
*prev = cur;
}
}
}
// Streamed units despawn on range-out — the latch and the baseline die with them.
latch.0.retain(|e, _| units.contains(*e));
seen.retain(|e, _| units.contains(*e));
}
/// The rebuild's **impact-kit replay** — the tail of the reference's `0x60abe0` (wow-re
/// `shapeshift-morph-cloud.md`): a display swap that finds the latch armed plays the latched
/// spell's impact kit as an ordinary discrete kit play ([`KitPlay::DISCRETE`] — full
/// `PlaySpellVisualKit`: effects, sound, anim, CharProcs) and clears the latch; a swap with no
/// latch plays nothing. Runs off [`crate::entities::DisplaySwapped`], which the swap writes as
/// it tears the visual down — the message crosses to the next frame, so the replay's instance
/// spawns onto (or pends for) the rebuilt body, never the corpse of the old one. Named
/// approximation: the reference's same-record+same-tag dedup `0x6208e0` is not modelled, so a
/// cold-cache shift-in — whose SPELL_GO impact instance was still PENDING at the drain and thus
/// survived — briefly runs that instance and the replay's twin together (one denser cloud, once
/// per session per model; the warm-cache GO instance dies in the drain like the reference's).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn replay_morph_kit(
mut swaps: MessageReader<crate::entities::DisplaySwapped>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut latch: ResMut<MorphLatch>,
mut play_seq: ResMut<super::PlaySeq>,
mut oneshots: MessageWriter<EmoteAnim>,
mut wounds: MessageWriter<WoundAnim>,
mut sounds: MessageWriter<SpellKitSound>,
mut fx: MessageWriter<SpellKitFx>,
mut chain: MessageWriter<ChainProcPlay>,
) {
for swap in swaps.read() {
// Consume-and-clear even when the kit resolves to nothing — the reference clears
// unconditionally at `0x60ad6c`.
let Some(spell_id) = latch.0.remove(&swap.entity) else {
continue; // no latch — a GM morph / revive swap plays nothing
};
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
continue; // no client data — no spell visuals (the DBC-resource degrade shape)
};
let Some(kit) = resolve_kit(spells, &visuals.0, spell_id, |s| s.impact, || None) else {
continue; // a morph spell with no impact kit — a silent swap
};
debug!("anim: morph replay ({}, spell {spell_id})", swap.entity);
let mut out = KitOut {
oneshots: &mut oneshots,
wounds: &mut wounds,
sounds: &mut sounds,
fx: &mut fx,
chain: &mut chain,
};
play_kit(
swap.entity,
spell_id,
&kit,
KitPlay::DISCRETE,
play_seq.next(),
&visuals.0,
&mut out,
);
}
}
+4
View File
@@ -70,6 +70,7 @@ use mount::reseat_mounts;
/// model in place (druid forms, GM morphs — ledger B69/F04) and one moving `SCALE_X` eases the
/// render scale (the reference's 2 s cosine smoothstep); both restamp the collision height.
mod live_display;
pub(crate) use live_display::DisplaySwapped;
use live_display::{refresh_live_display, tick_scale_ease};
/// Terrain conform (decisions 0482/0486): every flagged model (`GlobalModelFlags & 3 ∈ {1,3}` —
@@ -598,6 +599,9 @@ impl Plugin for EntitiesPlugin {
.add_message::<MissileSound>()
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
.add_message::<dest_fx::GroundBurst>()
// A live display-id swap's rebuild edge — consumed by the morph-latch replay
// (`crate::creature_anim`), the reference's `0x60abe0` impact-kit replay tail.
.add_message::<live_display::DisplaySwapped>()
.add_systems(Startup, setup_entities.after(AssetSet::Open))
// The map-scope teardown (`world_map::MapChange`): drop every display/material dedup
// so a map's assets actually die with it — the #bugs teleport leak.
@@ -45,6 +45,16 @@ const SCALE_EASE_SECS: f32 = 2.0;
#[derive(Component)]
pub(super) struct AppliedDisplay(pub(super) Option<u32>);
/// A live display-id swap happened on this entity — the visual was torn down and will rebuild.
/// The reference's rebuild `0x60abe0` ends by REPLAYING the pending-morph latch's impact kit
/// (wow-re `shapeshift-morph-cloud.md`); this edge is what carries "the rebuild ran" to that
/// replay (`crate::creature_anim`'s morph-latch watcher), which owns the latch and the kit
/// resolve. Written only when the rebuild actually fires (the diff branch — our `0x60ae10`).
#[derive(Message, Clone, Copy)]
pub(crate) struct DisplaySwapped {
pub(crate) entity: Entity,
}
/// A live render-scale ease toward [`NetEntity::scale`]: the reference's 2 s cosine smoothstep
/// (`0x614bbf`), ticked by [`tick_scale_ease`] as absolute writes (a mid-ease visual rebuild's
/// snap is simply overwritten next frame, so the ease survives it).
@@ -94,6 +104,7 @@ pub(super) fn refresh_live_display(
),
With<VisualAttached>,
>,
mut swapped: MessageWriter<DisplaySwapped>,
) {
for (entity, guid, mut net, store, applied, height, tf) in &mut entities {
let mut restamp = false;
@@ -108,6 +119,7 @@ pub(super) fn refresh_live_display(
);
net.display_id = Some(live);
restamp = true;
swapped.write(DisplaySwapped { entity });
// The full visual teardown set + our own diff key: children (parts, anchors,
// held roots, mount child) despawn, the per-instance visual components strip, and
// `attach_entity_visuals` rebuilds next frame(s) with the new display —
+50 -11
View File
@@ -828,18 +828,39 @@ pub(super) fn attach_spell_fx(
// teardown of the unit's visual (a live display swap, `live_display`; before 0835 a
// gear change; before B199's fix a mount transition) leaves the instance holding a
// dangling entity, and this gate's `root.is_some()` then means it is never rebuilt.
// The aura watcher cannot notice either: it is edge-driven off the slot set, and the
// aura never left it. 0835 named this open; here it closes. A **persistent** instance
// re-spawns onto the rebuilt body (its aura is still live, so its visual must be);
// a self-terminating flash or one already playing its `Decay` out simply goes.
// The reference's rebuild `0x60abe0` DRAINS the whole `+0xb4` list unconditionally
// and then RE-CREATES what must persist — the aura-state re-arm `0x5ff130`, the
// channel re-arm `0x612a30` (wow-re `shapeshift-morph-cloud.md`, which REFUTED the
// earlier survives-the-rebuild reading). Our edge-driven aura watcher cannot notice
// (the aura never left the slots), so re-arming the PERSISTENT instance here IS that
// re-create leg, in the one place that sees the drain. A one-shot goes, exactly as
// the reference's drained Mount Poof does — the shapeshift cloud is NOT survival:
// it is the pending-morph latch's impact-kit REPLAY after the rebuild
// (`creature_anim::spell_visual::replay_morph_kit`), and a decaying instance was
// ending anyway.
if let Some(root) = inst.root {
if commands.get_entity(root).is_ok() {
return true;
}
if !inst.persistent || inst.decaying {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fx",
&format!(
"kit collateral drain unit={unit} path={} decaying={}",
inst.path, inst.decaying
),
);
}
return false;
}
inst.root = None;
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fx",
&format!("kit collateral re-create unit={unit} path={}", inst.path),
);
}
}
// Still pending: a self-terminating instance gets a generous deadline so a unit whose
// model never materialises (the cube fallback) can't accumulate unspawnable flashes —
@@ -1073,12 +1094,14 @@ mod tests {
.collect()
}
/// **0835's named-open item, closed.** `FxAttached` lives on the unit and outlives its model —
/// exactly as the reference's per-CGUnit `+0xb4` effect list does — so a teardown of the unit's
/// visual (a live display swap) leaves each instance holding a dangling root, and the spawn
/// gate's `root.is_some()` then means it is never rebuilt. The aura watcher cannot notice: it
/// is edge-driven off the slot set, and the aura never left it. A **persistent** instance must
/// come back; a self-terminating flash must not.
/// **The drain-then-recreate law** (0835's named-open item closed; the reference byte-read
/// in wow-re `shapeshift-morph-cloud.md`): the rebuild drains EVERY effect node and then
/// re-creates what must persist. A teardown of the unit's visual leaves each instance
/// holding a dangling root; the persistent instance re-arms (our re-create leg — the
/// edge-driven aura watcher cannot notice, the aura never left the slots), the one-shot
/// flash goes exactly as the reference's drained Mount Poof does. The shapeshift cloud is
/// deliberately NOT this path's business — it is the morph latch's post-rebuild replay
/// (`creature_anim::spell_visual::replay_morph_kit`).
#[test]
fn a_persistent_instance_whose_root_died_as_collateral_is_re_armed() {
let (mut app, unit, roots) = standing(&[true, false]);
@@ -1090,10 +1113,26 @@ mod tests {
assert_eq!(
instances_of(&app, unit),
vec![(true, None)],
"the aura's instance is re-armed for the spawn pass; the flash is dropped",
"the aura's instance re-arms for the spawn pass; the one-shot drains",
);
}
/// A decaying persistent instance was already ending — the drain takes it too (a re-spawn
/// would replay its death rattle from nothing on the new body).
#[test]
fn a_decaying_instance_whose_root_died_as_collateral_is_dropped() {
let (mut app, unit, roots) = standing(&[true]);
app.world_mut()
.entity_mut(unit)
.get_mut::<FxAttached>()
.unwrap()
.instances[0]
.decaying = true;
app.world_mut().entity_mut(roots[0]).despawn();
app.update();
assert_eq!(instances_of(&app, unit), vec![]);
}
/// A live root is left strictly alone — the gate is "is it still there?", not "re-spawn me".
#[test]
fn a_live_root_is_never_disturbed() {
+5
View File
@@ -111,6 +111,7 @@ mod ui_gamma;
mod ui_gossip;
mod ui_guild;
mod ui_hide;
mod ui_honor;
mod ui_inspect;
mod ui_item_text;
mod ui_items;
@@ -580,6 +581,10 @@ pub fn run(build: BuildId) -> AppExit {
// The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item
// entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors.
.add_plugins(ui_inspect::InspectUiPlugin)
// The honor feed (decision 1512): the PRIVATE honor descriptor block as the snapshot both
// Honor tabs read, plus the inspect-honor round trip. After the inspect feed because it
// resolves that feed's target to address its request at.
.add_plugins(ui_honor::UiHonorPlugin)
// The dressing-room feed (decision 1060): the window's try-on intents → the player's own look
// with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect
// feed, whose shape it shares (intents in, a booth look out).
+6
View File
@@ -1273,6 +1273,12 @@ pub(crate) enum ClientCommand {
/// paints from the already-streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields. Sent anyway because
/// server-side it also sets our selection (`MiscHandler.cpp:945`), as the real client's does.
Inspect { target: u64 },
/// Ask for a player's honor stats (`MSG_INSPECT_HONOR_STATS`, `u64 target`) — the inspect
/// window's Honor tab (decision 1512). Unlike [`Self::Inspect`] this one is a **real** round
/// trip: the reply rides the same opcode and is the only source of another player's honor
/// numbers, since every field of the honor block is PRIVATE. A refusal is silence — the server
/// applies the same three gates as `CMSG_INSPECT` and simply does not answer.
InspectHonorStats { target: u64 },
// ── The player-trade arc (decision 0592; writer bodies in benilla-protocol
// `world/writer/trade.rs`). ─────────────────────────────────────────────────────────────
/// Offer to trade with a player (`CMSG_INITIATE_TRADE`, `u64 target`) — the UnitPopup TRADE row.
+28
View File
@@ -212,6 +212,10 @@ pub(super) fn apply_net_updates(
// the guard told you" is the client's to remember (`crate::poi_marker`).
ResMut<crate::poi_marker::PoiMarker>,
Option<Res<benilla_world::world_map::CurrentMap>>,
// The inspect-honor reply (decision 1512) — `MSG_INSPECT_HONOR_STATS` is the only
// source of another player's honor numbers, so the reply parks here and
// `crate::ui_honor` pushes it into the pane and fires `INSPECT_HONOR_UPDATE`.
ResMut<crate::ui_honor::InspectHonor>,
),
),
),
@@ -368,6 +372,7 @@ pub(super) fn apply_net_updates(
mut binder,
mut poi_marker,
current_map,
mut inspect_honor,
),
),
) = caches;
@@ -646,6 +651,29 @@ pub(super) fn apply_net_updates(
session::reputation_visible(list_id, &mut reputations)
}
SessionEvent::BindPoint { area } => home_bind.0 = Some(area),
// The honor arc's two inbound messages (decision 1512).
//
// The inspect reply REPLACES whatever is held, including for a different player: the
// reference's latch is a single slot, and a pane still showing the last target's
// kills is the failure keeping the old one produces.
SessionEvent::InspectHonorStats(stats) => inspect_honor.0 = Some(stats),
// An honor award: the combat-log line (name-resolved, so it queues) and the floating
// number, which are two different surfaces of one packet and are both the reference's.
// A DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text
// takes it signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a
// "+" only when the number is positive and therefore already expects the other case.
SessionEvent::PvpCredit(credit) => {
chat_log.push_pvp_credit(
credit.honor,
credit.victim_guid,
u8::try_from(credit.victim_rank).unwrap_or(0),
);
audio.15 .1.write(crate::ui_unit::CombatTextEvent {
message_type: "HONOR_GAINED",
data: Some(credit.honor.to_string()),
extra: None,
});
}
SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc),
SessionEvent::PlayerBound { binder: npc, area } => {
debug!("net: bound to area {area} by {npc:#x}");
+3
View File
@@ -873,6 +873,9 @@ fn writer_loop(
ClientCommand::QueryTime => w.query_time(),
// The inspect request (decision 0631) — no reply is awaited; see the writer.
ClientCommand::Inspect { target } => w.inspect(target),
// The inspect-honor query (decision 1512) — this one IS answered; the reply
// rides the same opcode back.
ClientCommand::InspectHonorStats { target } => w.inspect_honor_stats(target),
// The player-trade arc (decision 0592) — the CMSG verbs onto the P0 writers.
ClientCommand::InitiateTrade { target } => w.initiate_trade(target),
ClientCommand::BeginTrade => w.begin_trade(),
+12
View File
@@ -55,6 +55,12 @@ pub(crate) enum ChatEventKind {
/// The client-composed XP line (`SMSG_LOG_XPGAIN`, decision 0304) — the one `COMBAT_*`
/// family member modeled (0288 §3 keeps the rest for the combat-log content arc).
CombatXpGain,
/// The client-composed honor line (`SMSG_PVP_CREDIT`, decision 1512) — the XP line's twin,
/// and the second `COMBAT_*` family member modeled. Composed here rather than fired from the
/// wire because the packet carries a guid and a rank *number*: the sentence needs the
/// victim's NAME and their rank TITLE, so it is built after the name resolve exactly as
/// [`Self::CombatXpGain`] is.
CombatHonorGain,
RaidLeader,
RaidWarning,
RaidBossEmote,
@@ -105,6 +111,7 @@ impl ChatEventKind {
K::Loot,
K::Money,
K::CombatXpGain,
K::CombatHonorGain,
K::RaidLeader,
K::RaidWarning,
K::RaidBossEmote,
@@ -279,6 +286,7 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
K::Loot => "CHAT_MSG_LOOT",
K::Money => "CHAT_MSG_MONEY",
K::CombatXpGain => "CHAT_MSG_COMBAT_XP_GAIN",
K::CombatHonorGain => "CHAT_MSG_COMBAT_HONOR_GAIN",
K::RaidLeader => "CHAT_MSG_RAID_LEADER",
K::RaidWarning => "CHAT_MSG_RAID_WARNING",
K::RaidBossEmote => "CHAT_MSG_RAID_BOSS_EMOTE",
@@ -370,6 +378,8 @@ pub(crate) enum ChatGroup {
Money,
/// `COMBAT_XP_GAIN` — one kind, its own group (ref ChatFrame.lua l.235-237).
CombatXpGain,
/// `COMBAT_HONOR_GAIN` — one kind, its own group (ref ChatFrame.lua l.238-240).
CombatHonorGain,
}
/// `ChatTypeGroup` transcribed (ref ChatFrame.lua l.116-174): which kinds a group registration
@@ -417,6 +427,7 @@ pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] {
ChatGroup::Loot => &[K::Loot, K::Money],
ChatGroup::Money => &[K::Money],
ChatGroup::CombatXpGain => &[K::CombatXpGain],
ChatGroup::CombatHonorGain => &[K::CombatHonorGain],
}
}
@@ -449,6 +460,7 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
K::Loot => [0, 170, 0],
K::Money => [255, 255, 0],
K::CombatXpGain => [111, 111, 255],
K::CombatHonorGain => [224, 202, 10],
K::RaidLeader | K::RaidWarning | K::RaidBossEmote | K::BattlegroundLeader => {
[255, 219, 183]
}
+107
View File
@@ -91,6 +91,22 @@ enum Pending {
bonus: u32,
tries: u16,
},
/// An honor award awaiting the victim's name (`SMSG_PVP_CREDIT`; decision 1512) — the XP
/// node's twin, parked for the identical reason: the sentence's first `%s` is a NAME and the
/// packet carries a guid.
///
/// `rank` is the victim's **internal** rank as the packet sent it (0 = none), turned into a
/// title by the drain — which is the right place for it, because the title's GlobalString key
/// needs the victim's own **side and sex**, and those come out of the very `NameCache` record
/// the name resolve below is already waiting on (`SMSG_NAME_QUERY_RESPONSE` carries
/// race/class/gender beside the name). Resolving it at push time would have needed the
/// victim's descriptor, which is gone the moment their body despawns.
HonorGain {
victim: u64,
honor: i32,
rank: u8,
tries: u16,
},
/// A text emote (`SMSG_TEXT_EMOTE`) awaiting the PERFORMER's name — decision 1274.
///
/// Parked here for the reference's own reason, not merely for convenience: `0x49dbe0` composes
@@ -296,6 +312,25 @@ impl ChatLog {
}
}
/// Queue an honor award's chat line (`SMSG_PVP_CREDIT` → CHAT_MSG_COMBAT_HONOR_GAIN;
/// decision 1512). A credit with **no victim guid** is the bonus/objective form and needs no
/// resolve at all, so it composes here and now — the same fork [`Self::push_xp_gain`] takes.
pub(crate) fn push_pvp_credit(&mut self, honor: i32, victim: u64, rank: u8) {
if victim != 0 {
self.pending.push(Pending::HonorGain {
victim,
honor,
rank,
tries: 0,
});
} else {
self.pending.push(Pending::Event(ChatEvent::text_only(
ChatEventKind::CombatHonorGain,
honor_gain_line(None, None, honor),
)));
}
}
/// Queue a text emote's chat line (`SMSG_TEXT_EMOTE` → CHAT_MSG_TEXT_EMOTE; decision 1274) for
/// the performer-name resolve, then the `EmotesText`/`EmotesTextData` composition.
pub(crate) fn push_text_emote(&mut self, performer: u64, text_id: u32, target: String) {
@@ -419,6 +454,33 @@ pub(super) fn xp_gain_line(victim: Option<&str>, total: u32, bonus: u32) -> Stri
}
}
/// The honor line an `SMSG_PVP_CREDIT` becomes — the three GlobalStrings forms (COMBATLOG_HONORAWARD
/// :786, COMBATLOG_HONORGAIN :787, COMBATLOG_DISHONORGAIN :785), decision 1512.
///
/// **The fork is byte-VERIFIED** (wow-re `system/ui/scratch/honor-panel-law.md`, formatter
/// `0x625270`), and it is three-way, not two: no victim guid → AWARD; victim and `honor > 0` →
/// GAIN; victim and **`honor <= 0`** → DISHONOR. The boundary is `<=`, not `<` — a zero-honor kill
/// takes the dishonorable arm, which the pre-verdict reading had on the honorable side.
///
/// An absent `rank_title` formats an EMPTY rank slot rather than dropping the clause. That is the
/// reference's own shape — it is precisely the emptiness vmangos floors a rankless victim's rank at
/// 5 to avoid showing (1512), and compensating for it a second time on our side would hide what the
/// server is actually sending.
pub(super) fn honor_gain_line(
victim: Option<&str>,
rank_title: Option<&str>,
honor: i32,
) -> String {
match victim {
None => format!("You have been awarded {honor} honor points."),
Some(name) if honor <= 0 => format!("{name} dies, dishonorable kill."),
Some(name) => format!(
"{name} dies, honorable kill Rank: {} (Estimated Honor Points: {honor})",
rank_title.unwrap_or_default()
),
}
}
/// The discovery toast — GlobalStrings ERR_ZONE_EXPLORED ("Discovered: %s"), fired on every
/// exploration packet to the UIErrorsFrame (byte-verified: error-table route 1 →
/// `AddErrorMessage 0x4945b0` → UI_INFO_MESSAGE; decisions 0828/0829).
@@ -866,6 +928,51 @@ pub(super) fn feed_chat(
),
);
}
Pending::HonorGain {
victim,
honor,
rank,
tries,
} => {
let name = names.resolve(victim, &commands).map(str::to_string);
if name.is_none() && tries < NAME_MAX_TRIES {
still.push(Pending::HonorGain {
victim,
honor,
rank,
tries: tries + 1,
});
continue;
}
// **The side is the VICTIM's and the gender is OURS**, and that asymmetry is the
// reference's own (`0x625270`): the team digit is computed inline over the victim's
// faction template, while the gendered GlobalString resolve runs against the local
// player. It reads like a bug in the real client and it is what the bytes do; both
// halves are wow-re-VERIFIED. The victim's side rides the same name-query record we
// just resolved the name from; ours rides our own.
//
// A victim with no record is a creature — a racial leader is the only one the
// server ranks, at 19, and "Leader" is the same word on both sides — or an
// unanswered guid, so the side digit cannot change the answer there.
let team = names
.player_traits(victim)
.and_then(|(race, _, _)| crate::ui_unit::race_faction_group(race))
.map_or(0, |group| u8::from(group == "Alliance"));
let female = self_guid
.0
.and_then(|g| names.player_traits(g))
.is_some_and(|(_, _, gender)| gender == 1);
let title = script.pvp_rank_title(rank, team, female);
let name = name.unwrap_or_else(|| "Unknown".into());
route(
&mut script,
&mut windows,
&ChatEvent::text_only(
ChatEventKind::CombatHonorGain,
honor_gain_line(Some(&name), title.as_deref(), honor),
),
);
}
Pending::TextEmote {
performer,
text_id,
+11 -4
View File
@@ -58,10 +58,16 @@ impl Default for ChatWindows {
ChatGroup::Skill,
ChatGroup::Loot,
],
// WINDOW 2 "Combat Log": of its chat-cache list, MONEY and COMBAT_XP_GAIN are
// the groups the current kind set carries (the rest of the combat/spell block is
// the combat-log arc; XP joined with the ding arc, 0304).
vec![ChatGroup::Money, ChatGroup::CombatXpGain],
// WINDOW 2 "Combat Log": of its chat-cache list, MONEY, COMBAT_XP_GAIN and
// COMBAT_HONOR_GAIN are the groups the current kind set carries (the rest of the
// combat/spell block is the combat-log arc; XP joined with the ding arc, 0304,
// and honour with the honor arc, 1512 — the reference registers the two on the
// same frame one line apart, ChatFrame.lua l.2428-2429).
vec![
ChatGroup::Money,
ChatGroup::CombatXpGain,
ChatGroup::CombatHonorGain,
],
],
tell_alert_left: 0.0,
default_language: String::new(),
@@ -176,6 +182,7 @@ pub(crate) fn compose(
| K::Loot
| K::Money
| K::CombatXpGain
| K::CombatHonorGain
| K::BgSystemNeutral
| K::BgSystemAlliance
| K::BgSystemHorde => event.text.clone(),
+37 -1
View File
@@ -269,6 +269,42 @@ fn level_up_lines_follow_the_reference_order_and_forms() {
);
}
/// The three honor forms (COMBATLOG_HONORAWARD / COMBATLOG_HONORGAIN / COMBATLOG_DISHONORGAIN,
/// GlobalStrings :786/:787/:785) and the fork between them, decision 1512.
///
/// The empty-rank case is asserted deliberately: it is what the server's floor-at-5 exists to
/// prevent, so a change that silently starts hiding the clause instead would pass unnoticed here
/// without it.
#[test]
fn honor_gain_lines_pick_the_reference_form() {
assert_eq!(
super::feed::honor_gain_line(None, None, 42),
"You have been awarded 42 honor points."
);
assert_eq!(
super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 137),
"Grimtusk dies, honorable kill Rank: Sergeant (Estimated Honor Points: 137)"
);
// A dishonorable kill: vmangos sends the same packet with a negative honor
// (`HonorMgr.cpp:807`), and the client's fork is on the sign.
assert_eq!(
super::feed::honor_gain_line(Some("Innkeeper Renee"), None, -37),
"Innkeeper Renee dies, dishonorable kill."
);
// The BOUNDARY, byte-verified at `0x625270`: the test is `honor <= 0`, so a zero-honor kill
// takes the dishonorable arm. The pre-verdict reading had `< 0` and put this one on the
// honorable side, where it would have printed "Rank: (Estimated Honor Points: 0)".
assert_eq!(
super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 0),
"Grimtusk dies, dishonorable kill."
);
// No rank title: the clause stays, empty — the reference's own shape.
assert_eq!(
super::feed::honor_gain_line(Some("Grimtusk"), None, 5),
"Grimtusk dies, honorable kill Rank: (Estimated Honor Points: 5)"
);
}
#[test]
fn xp_gain_lines_pick_the_reference_form() {
// COMBATLOG_XPGAIN_FIRSTPERSON / its EXHAUSTION1 rested form / _UNNAMED (GlobalStrings
@@ -895,7 +931,7 @@ fn every_kind_is_in_all() {
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), before, "a kind is listed twice in ALL");
assert_eq!(before, 36, "36 kinds — update this when the kind set grows");
assert_eq!(before, 37, "37 kinds — update this when the kind set grows");
}
#[test]
+341
View File
@@ -0,0 +1,341 @@
//! The app-side **honor feed** (decision 1512) — the local player's PRIVATE honor descriptor
//! fields turned into the snapshot both Honor tabs read, the inspect-honor request/reply round
//! trip, and the `SMSG_PVP_CREDIT` award turned into its chat line and floating number.
//!
//! The display law lives in `benilla-ui`'s `script::pvp`; this is the **data** law it consumes,
//! and there are three pieces of it.
//!
//! ## 1 · The self snapshot is one descriptor read, and every field of it is PRIVATE
//!
//! The whole honor block (`PLAYER_FIELD_SESSION_KILLS` … `PLAYER_FIELD_BYTES2`) streams for
//! nobody but the local player, so there is no per-unit form of this feed and there cannot be one.
//! What a *foreign* player exposes is exactly two things — the PUBLIC current-rank byte, which
//! rides [`crate::ui_unit`]'s snapshot, and whatever `MSG_INSPECT_HONOR_STATS` chooses to answer.
//! That asymmetry is the shape of the entire arc.
//!
//! ## 2 · The two events are a field diff, and the reference watches **exactly three fields**
//!
//! The pane repaints on `PLAYER_PVP_KILLS_CHANGED` and `PLAYER_PVP_RANK_CHANGED`, which the real
//! engine fires from field watches. wow-re carved the watch table (`0x467e70`, callback
//! `0x5de4b0` carrying the event id) and there are **three registrations and no more**:
//!
//! | watched | fires |
//! |---|---|
//! | `PLAYER_FIELD_SESSION_KILLS` | `PLAYER_PVP_KILLS_CHANGED` (523) |
//! | `PLAYER_BYTES_3` | `PLAYER_PVP_RANK_CHANGED` (524) |
//! | `PLAYER_FIELD_BYTES2` byte 0 | `PLAYER_PVP_RANK_CHANGED` (524) |
//!
//! **Nothing watches the yesterday / this-week / last-week / lifetime / contribution fields, and
//! nothing watches `+0x102b` (the highest-lifetime rank).** That is not an omission in the client;
//! it is *why* `HonorFrame.lua` refreshes those rows on `PLAYER_ENTERING_WORLD` alone. The two
//! facts are one fact, and a feed that fired `KILLS_CHANGED` on a lifetime total — which this one
//! did until the verdict landed — would be firing an event the real client has no source for.
//!
//! One divergence, stated: the reference watches the **whole `PLAYER_BYTES_3` dword**, so a
//! drunkenness change fires `PLAYER_PVP_RANK_CHANGED` there too. We watch byte 3 alone. The
//! repaint that spurious fire produces is byte-identical to the one before it, so the difference
//! is unobservable; carrying the other three bytes in an honor snapshot to reproduce it would cost
//! a field nothing reads.
//!
//! ## 3 · The inspect round trip is a real one, unlike `CMSG_INSPECT`
//!
//! 0631's inspect request is fire-and-forget: the gear was already streamed and `SMSG_INSPECT`
//! echoes the guid. This one is the opposite — `MSG_INSPECT_HONOR_STATS` is the *only* source of
//! another player's honor numbers, the reply rides the **same opcode** as the request, and the
//! reference's pane gates on `HasInspectHonorData()` precisely because the data may not be here
//! yet. So: the pane asks, we resolve the inspect target's guid and send, the reply lands in
//! [`InspectHonor`], and `INSPECT_HONOR_UPDATE` tells the pane to repaint.
//!
//! **A refusal is silent by construction.** The server answers nothing when the target is gone,
//! out of the 10-yard `INSPECT_DISTANCE`, or attackable (`MiscHandler.cpp:962-977`) — there is no
//! error shape on this opcode. The pane's own `OnShow` re-asks whenever it holds nothing, which is
//! the reference's retry and the only one there is.
use bevy::prelude::*;
use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript};
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::{UiInput, VmMemo};
/// The inspect-honor reply we currently hold, or `None` before one lands.
///
/// Keyed by nothing but its own `player_guid`: the reply carries whose it is, and the pane is
/// keyed by a unit token, so the app is where the two are matched. One slot rather than a map —
/// the reference's `HasInspectHonorData` is a single latch, and a window that can only inspect one
/// player at a time needs no more.
#[derive(Resource, Default)]
pub(crate) struct InspectHonor(pub(crate) Option<benilla_protocol::messages::InspectHonorStats>);
/// What the last push told this VM, so the feed pushes and fires only on real change.
///
/// Behind a [`VmMemo`] (1290/1291) for the reason every other feed's is: a `/reload` replaces the
/// VM without despawning the world, and a memory of what the *old* VM was told would leave the new
/// one with an empty pane and no event ever coming.
#[derive(Resource, Default)]
struct HonorFeedState {
vm: VmMemo<HonorFeedMemo>,
}
/// The per-VM change bases — the self snapshot and the inspect reply's guid.
#[derive(Default)]
struct HonorFeedMemo {
last: Option<HonorState>,
last_inspect: Option<u64>,
}
pub(crate) struct UiHonorPlugin;
impl Plugin for UiHonorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InspectHonor>()
.init_resource::<HonorFeedState>()
.add_systems(Update, feed_honor.in_set(UiInput));
}
}
/// Read the honor block off our own descriptor, or `None` while none of it has streamed.
///
/// **`None` and all-zeroes are different states and the difference is visible**: a fresh character
/// who has never fought has every counter at 0 *and* the fields present, while a player who has
/// only just entered the world may have none of them yet. The reference paints the second as
/// blank, not as zero, so the feed pushes nothing until at least one field arrives rather than
/// inventing a zeroed snapshot.
fn honor_snapshot(store: &ObjectStore) -> Option<HonorState> {
let f = &store.0;
let session = f.player_session_kills();
let yesterday = f.player_yesterday_kills();
let last_week = f.player_last_week_kills();
let this_week = f.player_this_week_kills();
// The rank byte is PUBLIC and arrives with the unit block, so it alone is a poor presence
// test; the honor block proper is what we wait for.
session?;
Some(HonorState {
session_hk: session.map_or(0, |(hk, _)| hk),
session_dk: session.map_or(0, |(_, dk)| dk),
yesterday_hk: yesterday.map_or(0, |(hk, _)| hk),
yesterday_dk: yesterday.map_or(0, |(_, dk)| dk),
yesterday_honor: f.player_yesterday_contribution().unwrap_or(0),
this_week_hk: this_week.map_or(0, |(hk, _)| hk),
this_week_honor: f.player_this_week_contribution().unwrap_or(0),
last_week_hk: last_week.map_or(0, |(hk, _)| hk),
last_week_dk: last_week.map_or(0, |(_, dk)| dk),
last_week_honor: f.player_last_week_contribution().unwrap_or(0),
last_week_standing: f.player_last_week_rank().unwrap_or(0),
lifetime_hk: f.player_lifetime_honorable_kills().unwrap_or(0),
lifetime_dk: f.player_lifetime_dishonorable_kills().unwrap_or(0),
// The HIGHEST lifetime rank (PRIVATE, `PLAYER_FIELD_BYTES` byte 3) …
highest_rank: f.player_honor_rank().unwrap_or(0),
// … and the CURRENT one (PUBLIC, `PLAYER_BYTES_3` byte 3). Two bytes, two fields, and
// they are equal for anyone who has never ranked down — which is exactly why reading one
// for the other would have shipped green (decision 1512).
rank: f.player_pvp_rank().unwrap_or(0),
rank_bar: f.player_honor_rank_bar().unwrap_or(0),
})
}
/// Which of the two reference events a change between two snapshots deserves — the module doc's
/// three-row watch table, written out. A first push is both: the pane has never painted, and
/// either event repaints it.
///
/// **Most of `HonorState` fires nothing at all.** The weekly figures, the two lifetime totals and
/// the highest-lifetime rank are unwatched in the real client, so they ride the pane's world-entry
/// repaint and move here in silence. Listing the watched fields positively — rather than
/// "everything that is not a rank byte", which is what this did before the RE verdict — is what
/// keeps a field added later from inventing an event for itself.
fn events_for(before: Option<&HonorState>, after: &HonorState) -> (bool, bool) {
let Some(b) = before else {
return (true, true);
};
// `PLAYER_FIELD_SESSION_KILLS`, both halves — the one field behind `PLAYER_PVP_KILLS_CHANGED`.
let kills = |h: &HonorState| (h.session_hk, h.session_dk);
// `PLAYER_BYTES_3` byte 3 and `PLAYER_FIELD_BYTES2` byte 0 — the two behind
// `PLAYER_PVP_RANK_CHANGED`. NOT `highest_rank`: that is `+0x102b`, and nothing watches it.
let ranks = |h: &HonorState| (h.rank, h.rank_bar);
(kills(b) != kills(after), ranks(b) != ranks(after))
}
/// Push the self snapshot and the inspect reply, fire what moved, and drain the pane's request.
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
fn feed_honor(
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
inspect_target: Res<crate::ui_inspect::InspectTarget>,
selection: Res<crate::target::Selection>,
group: Res<crate::ui_party::GroupState>,
mut inspect_honor: ResMut<InspectHonor>,
mut state: ResMut<HonorFeedState>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
let memo = state.vm.get(&script);
// --- the self snapshot -------------------------------------------------------------------
if let Ok(store) = self_store.single() {
if let Some(fresh) = honor_snapshot(store) {
if memo.last.as_ref() != Some(&fresh) {
let (kills, ranks) = events_for(memo.last.as_ref(), &fresh);
script.set_honor(Some(fresh));
memo.last = Some(fresh);
if kills {
script.fire_event("PLAYER_PVP_KILLS_CHANGED", vec![]);
}
if ranks {
script.fire_event("PLAYER_PVP_RANK_CHANGED", vec![]);
}
}
}
}
// --- the inspect reply -------------------------------------------------------------------
//
// **A reply is only valid while it is about the player currently being inspected.** The
// reference's `HasInspectHonorData()` is the latch its pane's `OnShow` gates on: hold stale
// data and inspecting a SECOND player repaints the FIRST one's kills, with no request ever
// sent and nothing on screen to say so.
//
// **The real client's latch is invalidated by exactly one thing, and it is not this one**
// (wow-re `honor-panel-law.md`): the slot is a single un-keyed store, and `0x4c6f70` — reached
// from `NotifyInspect` — is its only GUID writer *and* its only invalidator. A `NotifyInspect`
// naming a different player zeroes both flags; the same player is a no-op; there is no timeout;
// and `ClearInspectPlayer` (the stock `InspectFrame_OnHide`) clears it outright.
//
// **We invalidate on the inspected TOKEN's guid moving instead, deliberately.** Our inspect
// window re-resolves its token every frame so the paper doll follows a re-target (0631), and
// the honor page reads that same token rather than the reference's hardcoded `"target"` — so
// matching the reference's latch exactly would let one window show two different players'
// data at once, on its two tabs. The reference cannot notice because its two pages disagree
// about whose player they show in the first place. Ours agree, and this comparison is what
// keeps them agreeing.
//
// (`ClearInspectPlayer` is covered too, one level up: it drops the token, `inspected` reads
// `None`, and the mismatch below clears the slot.)
let inspected = inspect_target
.token
.as_deref()
.and_then(|t| crate::ui_unit::player_token_guid(t, &selection, &group));
if inspect_honor
.0
.as_ref()
.is_some_and(|reply| Some(reply.player_guid) != inspected)
{
// Dropped from the RESOURCE, not merely filtered on the way out: a reply nobody may read
// is not data we are keeping, and leaving it here would make `HasInspectHonorData` and
// this store disagree about what is held.
inspect_honor.0 = None;
}
let held = inspect_honor.0.as_ref().map(|r| r.player_guid);
if memo.last_inspect != held {
script.set_inspect_honor(inspect_honor.0.as_ref().map(|r| {
let (session_hk, session_dk) = r.session_kills();
InspectHonorData {
guid: r.player_guid,
session_hk,
session_dk,
yesterday_hk: r.yesterday_hk,
yesterday_honor: r.yesterday_honor,
this_week_hk: r.this_week_hk,
this_week_honor: r.this_week_honor,
last_week_hk: r.last_week_hk,
last_week_honor: r.last_week_honor,
last_week_standing: r.last_week_rank,
lifetime_hk: r.lifetime_hk,
lifetime_dk: r.lifetime_dhk,
highest_rank: r.highest_rank,
rank_bar: r.rank_bar,
}
}));
memo.last_inspect = held;
// Fired on a *clear* as well as on an arrival: the pane's handler re-reads
// `GetInspectHonorData`, and a window left showing the previous player's kills is the
// failure a silent clear produces.
script.fire_event("INSPECT_HONOR_UPDATE", Vec::<ScriptValue>::new());
}
// --- the pane's request ------------------------------------------------------------------
//
// `RequestInspectHonorData()` takes no argument — the engine holds the inspected player, so
// the app resolves it, exactly as `NotifyInspect`'s token is resolved. The token is re-read
// now rather than remembered from the notify, so a re-target between opening the window and
// opening its Honor tab asks about whoever is actually being inspected.
let requests = script.take_inspect_honor_requests();
if requests > 0 {
match inspected {
Some(guid) => {
debug!("honor: inspect-honor request -> {guid:#x}");
let _ = commands
.0
.send(ClientCommand::InspectHonorStats { target: guid });
}
// No inspect target (or it is not streamed): nothing to ask about. The pane will ask
// again from its next `OnShow`, which is the reference's only retry.
None => debug!("honor: inspect-honor request with no resolvable target — not sent"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> HonorState {
HonorState {
session_hk: 3,
lifetime_hk: 900,
rank: 8,
highest_rank: 9,
rank_bar: 128,
..Default::default()
}
}
/// The event split is the whole point of [`events_for`]: a kill must not claim a rank change,
/// and a rank must not claim a kill. An addon that registers only one of the two is what makes
/// the distinction observable.
#[test]
fn each_event_fires_only_for_its_own_half() {
let base = state();
let mut killed = base;
killed.session_hk += 1;
assert_eq!(events_for(Some(&base), &killed), (true, false));
let mut ranked = base;
ranked.rank = 9;
assert_eq!(events_for(Some(&base), &ranked), (false, true));
// The bar moving is a rank event too — it is the rank's own progress, and the reference's
// pane redraws the bar from the same handler as the title.
let mut barred = base;
barred.rank_bar = 200;
assert_eq!(events_for(Some(&base), &barred), (false, true));
// The unwatched half: a weekly figure, a lifetime total and the highest-lifetime rank all
// move in silence, because the real client registers no watch on any of them (wow-re's
// carve of `0x467e70` — the module doc's table). This is the assertion the pre-verdict
// implementation failed: it fired `KILLS_CHANGED` for all three.
for mutate in [
(|h: &mut HonorState| h.last_week_standing = 42) as fn(&mut HonorState),
|h: &mut HonorState| h.lifetime_hk = 5_000,
|h: &mut HonorState| h.yesterday_honor = 900,
|h: &mut HonorState| h.highest_rank = 18,
] {
let mut quiet = base;
mutate(&mut quiet);
assert_eq!(
events_for(Some(&base), &quiet),
(false, false),
"an unwatched field must fire nothing"
);
}
}
/// A first push has to fire both, or a pane whose numbers arrived before it existed never
/// paints — the `ui_unit` "first resolve counts as a transition" rule.
#[test]
fn the_first_push_fires_both() {
assert_eq!(events_for(None, &state()), (true, true));
}
}
@@ -945,15 +945,17 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
load_xml(&s, "PetPaperDollFrame.xml");
load_xml(&s, "ReputationFrame.xml");
load_xml(&s, "SkillFrame.xml");
load_xml(&s, "HonorFrame.xml");
s.set_unit("player", Some(player_unit()));
// THE INVARIANT the fix rests on: a page's `id=` is its slot in this window's own tab row, and
// `BENILLA_CHARACTERFRAME_SUBFRAMES` is the same 1:1 mapping written the other way round. It
// did its job: this loop is what made the Reputation page's arrival LOUD, since Skills had to
// move from 3 to 4 in the same breath. The four slots are the reference's own now
// (Character/Pet/Reputation/Skills, ref `CharacterFrame.xml:79-168`); only Honor, its tab 5, is
// still out, and it sits past the end where its absence shifts nothing.
for i in 1..=4 {
// move from 3 to 4 in the same breath. All five slots are the reference's own now
// (Character/Pet/Reputation/Skills/Honor, ref `CharacterFrame.xml:79-168`), so the loop covers
// the whole row rather than a prefix of it — there is nothing left past the end for a page to
// arrive into unnoticed.
for i in 1..=5 {
let id: i64 = s
.eval(&format!(
"return getglobal(BENILLA_CHARACTERFRAME_SUBFRAMES[{i}]):GetID()"
@@ -0,0 +1,466 @@
//! The shipped **Honor tab** (`assets/ui/HonorFrame.xml`) driven end-to-end, engine-only (no
//! Bevy) — the per-window test module the skills/reputation files establish (decision 1512).
//!
//! What it pins is the PAINT law, the half `benilla-ui`'s own `script::pvp::tests` structurally
//! cannot reach: that module drives the thirteen globals and asserts their tuples, and stops at the
//! seam. Everything below is about what those tuples become on screen — which figure lands in which
//! of the thirteen row slots, what the rank block does with a nil title, which badge file the
//! arithmetic names, and the weekly/session repaint split.
//!
//! Two of these are the arc's own traps, and they are why this file exists rather than trusting the
//! engine tests:
//!
//! - **The badge is the VISUAL rank and the title is the INTERNAL one.** They differ by four, both
//! are numbers, and both come out of one call. A pane that indexed the badge by the internal rank
//! would put a Sergeant's art on a Knight-Captain and no tuple assertion would notice.
//! - **The weekly sections repaint on world entry only.** A pane that repainted everything on every
//! kill would look identical on screen for a whole session and differ only in what it costs —
//! which is to say, it would never be caught by looking.
use benilla_ui::script::{HonorState, UiScript, UnitState};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the
/// character/reputation tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The fixture snapshot — a rank-12 Alliance character with a figure in every bucket, all thirteen
/// distinct so a swapped pair of rows cannot pass. Deliberately the same numbers as the protocol
/// crate's 50-byte `inspect_honor_stats_golden`, so a failure here can be read straight against the
/// bytes one layer down.
fn state() -> HonorState {
HonorState {
session_hk: 17,
session_dk: 2,
yesterday_hk: 41,
yesterday_dk: 0,
yesterday_honor: 640,
this_week_hk: 123,
this_week_honor: 1_250,
last_week_hk: 420,
last_week_dk: 0,
last_week_honor: 8_431,
last_week_standing: 57,
lifetime_hk: 3_907,
lifetime_dk: 12,
// Two DIFFERENT ranks on purpose: the current one the badge and title draw, and the
// higher lifetime best the "Highest Rank" row shows. A pane that read one for the other
// passes every same-value fixture.
rank: 12,
highest_rank: 14,
// Three quarters through the rank (the binding's constant × 191).
rank_bar: 191,
}
}
/// The eight rank titles this fixture can ask for, defined as the install's `GlobalStrings.lua`
/// defines them. Set by hand rather than read off the chain so the pane's law is pinned on a
/// machine with no client data; [`the_real_global_strings_name_the_rank`] is the same assertion
/// against the player's actual file.
const RANK_GLOBALS: &str = r#"
NONE = "None"
RANK = "Rank"
PLAYER_LEVEL = "Level %d %s %s"
PVP_RANK_12_1 = "Knight-Captain"
PVP_RANK_12_0 = "Legionnaire"
PVP_RANK_14_1 = "Lieutenant Commander"
PVP_RANK_14_0 = "Champion"
"#;
fn load_page(s: &UiScript) {
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"TextStatusBar.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"OptionsFrameTemplates.xml",
"CharacterFrame.xml",
"HonorFrame.xml",
] {
load_xml(s, file);
}
}
/// An Alliance, male, level-60 player — the two facts the rank title's GlobalString key is built
/// from (side → the team digit, sex → the `_FEMALE` twin), plus the level line's input.
fn alliance_player() -> UnitState {
UnitState {
exists: true,
level: 60,
sex: 2,
faction_group: Some("Alliance".into()),
race: Some("Human".into()),
class: Some("Warrior".into()),
pvp_rank: 12,
..UnitState::default()
}
}
/// The Honor page open on its tab with [`state`] pushed, as world entry leaves it (`weekly` true,
/// so every section has been painted at least once).
fn shown_honor_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(RANK_GLOBALS).unwrap();
load_page(&s);
s.set_unit("player", Some(alliance_player()));
s.set_honor(Some(state()));
s.run(r#"ToggleCharacter("HonorFrame")"#).unwrap();
s.run("HonorFrame_Update(1)").unwrap();
s.resolve();
s
}
fn text(s: &mut UiScript, frame: &str) -> String {
s.eval::<String>(&format!("return {frame}:GetText()"))
.unwrap_or_else(|e| panic!("{frame}:GetText(): {e}"))
}
/// Every figure the snapshot carries lands in its own row. Thirteen distinct numbers against
/// thirteen slots: any transposition — the two lifetime totals, this week against last week, the
/// standing against a kill count — fails here and only here.
#[test]
fn every_figure_lands_in_its_own_row() {
let mut s = shown_honor_page();
for (frame, want) in [
("HonorFrameCurrentHKValue", "17"),
("HonorFrameCurrentDKValue", "2"),
("HonorFrameYesterdayHKValue", "41"),
("HonorFrameYesterdayContributionValue", "640"),
("HonorFrameThisWeekHKValue", "123"),
("HonorFrameThisWeekContributionValue", "1250"),
("HonorFrameLastWeekHKValue", "420"),
("HonorFrameLastWeekContributionValue", "8431"),
("HonorFrameLastWeekStandingValue", "57"),
("HonorFrameLifeTimeHKValue", "3907"),
("HonorFrameLifeTimeDKValue", "12"),
// The HIGHEST rank's title, not the current one — rank 14, not rank 12.
("HonorFrameLifeTimeRankValue", "Lieutenant Commander"),
] {
assert_eq!(text(&mut s, frame), want, "{frame}");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rank block: the title is keyed by the INTERNAL rank and the badge by the VISUAL one, which
/// differ by four. This is the arc's central conflation trap (decision 1512) and the assertion that
/// makes it impossible to ship.
#[test]
fn the_title_is_the_internal_rank_and_the_badge_is_the_visual_one() {
let mut s = shown_honor_page();
assert_eq!(text(&mut s, "HonorFrameCurrentPVPTitle"), "Knight-Captain");
// Internal 12 → visual 8. The line reads "(Rank 8)", never "(Rank 12)".
assert_eq!(text(&mut s, "HonorFrameCurrentPVPRank"), "(Rank 8)");
assert!(
s.eval::<bool>("return HonorFramePvPIcon:IsShown()")
.unwrap(),
"a ranked character shows a badge"
);
assert_eq!(
s.eval::<String>("return HonorFramePvPIcon:GetTexture()")
.unwrap(),
"Interface\\PvPRankBadges\\PvPRank08",
"the badge file is the VISUAL rank, zero-padded"
);
}
/// Rank 0 — every character who has never taken an honorable kill. The title's GlobalString does
/// not exist (there is no `PVP_RANK_0_*`), the binding answers nil, and the pane says NONE with no
/// badge. The nil is the mechanism, so this is the case that proves the pane never invents a title.
#[test]
fn an_unranked_character_reads_none_and_shows_no_badge() {
let mut s = shown_honor_page();
s.set_honor(Some(HonorState {
rank: 0,
highest_rank: 0,
..state()
}));
s.set_unit(
"player",
Some(UnitState {
pvp_rank: 0,
..alliance_player()
}),
);
s.run("HonorFrame_Update(1)").unwrap();
s.resolve();
assert_eq!(text(&mut s, "HonorFrameCurrentPVPTitle"), "None");
assert_eq!(text(&mut s, "HonorFrameCurrentPVPRank"), "(Rank 0)");
assert_eq!(text(&mut s, "HonorFrameLifeTimeRankValue"), "None");
assert!(
!s.eval::<bool>("return HonorFramePvPIcon:IsShown()")
.unwrap(),
"rank 0 has no badge to show"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar takes the 0..1 fraction straight, and wears the player's own faction colour. Alliance
/// navy here; the `else` arm is Horde red for everyone else, including a unit whose side has not
/// resolved — a bar with no colour at all would read as a broken pane.
#[test]
fn the_bar_takes_the_fraction_and_the_faction_colour() {
let s = shown_honor_page();
// Against the binding's own arithmetic, not against `191/255`. The client MULTIPLIES by the
// f32 nearest 1/255 (`0x3B808081`, wow-re `honor-panel-law.md` `0x51aace`) rather than
// dividing, and the two answers differ in the eighth decimal — a tolerance loose enough to
// accept both would stop pinning the correction the moment it was made.
let want = 191.0 * f64::from(f32::from_bits(0x3B80_8081));
let binding = s.eval::<f64>("return GetPVPRankProgress()").unwrap();
assert!(
(binding - want).abs() < 1e-12,
"the binding answers 191 × the reference's own constant = {want}, got {binding}"
);
// The BAR is checked at `f32`, and that is not a slackened assertion — a `StatusBar`'s value is
// a C `float` in the real client and an `f32` in ours, so the round trip through the widget is
// *supposed* to lose the tail. Asserting the exact `f64` here would be asserting that our
// status bars are wider than the reference's.
let value = s
.eval::<f64>("return HonorFrameProgressBar:GetValue()")
.unwrap();
assert_eq!(
value as f32, want as f32,
"the bar holds the binding's answer at widget precision"
);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return HonorFrameProgressBar:GetStatusBarColor()")
.unwrap();
assert!(
(r - 0.05).abs() < 1e-6 && (g - 0.15).abs() < 1e-6 && (b - 0.36).abs() < 1e-6,
"Alliance navy, got ({r}, {g}, {b})"
);
}
/// The repaint split the reference wrote and we kept: a kill moves the session and rank blocks, and
/// leaves the three weekly sections alone until world entry says otherwise.
///
/// Driven through the real `OnEvent` rather than by calling `HonorFrame_Update` directly, because
/// the flag's *derivation from the event name* is the half that can be got wrong.
#[test]
fn a_kill_repaints_the_session_but_not_the_week() {
let mut s = shown_honor_page();
// Everything moves in the snapshot — including a weekly figure, which is the bait.
s.set_honor(Some(HonorState {
session_hk: 18,
this_week_hk: 124,
..state()
}));
s.run(r#"HonorFrame_OnEvent("PLAYER_PVP_KILLS_CHANGED")"#)
.unwrap();
s.resolve();
assert_eq!(
text(&mut s, "HonorFrameCurrentHKValue"),
"18",
"the session block follows a kill"
);
assert_eq!(
text(&mut s, "HonorFrameThisWeekHKValue"),
"123",
"the weekly block does NOT — it moves at the server's weekly maintenance"
);
// World entry is the flag that says "repaint those too".
s.run(r#"HonorFrame_OnEvent("PLAYER_ENTERING_WORLD")"#)
.unwrap();
s.resolve();
assert_eq!(text(&mut s, "HonorFrameThisWeekHKValue"), "124");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rank title against the **real shipped strings**, end to end — the leg the hand-set
/// [`RANK_GLOBALS`] above cannot reach, and the one that proves the key we build
/// (`PVP_RANK_<internal>_<team>`) is the key the player's own file actually defines.
///
/// The two sides matter: rank 12 is "Knight-Captain" to the Alliance and "Legionnaire" to the
/// Horde, so a hardcoded team digit passes one and fails the other. Skips without client data.
#[test]
fn the_real_global_strings_name_the_rank() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
load_page(&s);
s.set_honor(Some(state()));
for (group, want) in [("Alliance", "Knight-Captain"), ("Horde", "Legionnaire")] {
s.set_unit(
"player",
Some(UnitState {
faction_group: Some(group.into()),
..alliance_player()
}),
);
s.run(r#"ToggleCharacter("HonorFrame")"#).ok();
s.run("HonorFrame_Update(1)").unwrap();
s.resolve();
assert_eq!(
text(&mut s, "HonorFrameCurrentPVPTitle"),
want,
"rank 12 to the {group}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The inspect window's honor page ─────────────────────────────────────────────────────────────
//
// Same twelve rows, same two painters, a different data source: the character page reads a
// descriptor block we hold, this one reads a reply that had to be asked for. The tests below are
// about that difference — the twelve-value tuple landing in the right slots, and the ask/hold latch.
use benilla_ui::script::InspectHonorData;
use std::collections::HashMap;
/// The reply for the inspected player, carrying the same figures as [`state`] so a row that took
/// its number from the wrong pane's feed is visible as a wrong *value*, not just a wrong layout.
fn inspect_reply() -> InspectHonorData {
let s = state();
InspectHonorData {
guid: 0x0000_0001_0000_2AB3,
session_hk: s.session_hk,
session_dk: s.session_dk,
yesterday_hk: s.yesterday_hk,
yesterday_honor: s.yesterday_honor,
this_week_hk: s.this_week_hk,
this_week_honor: s.this_week_honor,
last_week_hk: s.last_week_hk,
last_week_honor: s.last_week_honor,
last_week_standing: s.last_week_standing,
lifetime_hk: s.lifetime_hk,
lifetime_dk: s.lifetime_dk,
// The reply's own `highestRank` — `GetInspectHonorData`'s twelfth return, which the
// reference destructures as `lifetimeRank`.
highest_rank: s.highest_rank,
rank_bar: s.rank_bar,
}
}
/// The inspect window open on its Honor tab, over a rank-12 Alliance target with [`inspect_reply`]
/// held.
fn shown_inspect_honor_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(RANK_GLOBALS).unwrap();
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
// Before InspectFrame.xml — the honor page inherits this file's row templates and calls
// its two shared painters, and `inherits=` resolves at load (the manifest's own order).
"HonorFrame.xml",
"InspectFrame.xml",
] {
load_xml(&s, file);
}
s.set_unit("target", Some(alliance_player()));
// The PLAYER snapshot is required even though this page is about somebody else, and the reason
// is the reference's own asymmetry: `BenillaHonorPane_SetRank` passes the inspected token for
// the BAR COLOUR, but `GetPVPRankInfo` takes no unit at all and keys its title off the local
// player's side and sex. That is faithful — the ref calls it the same way — and it is sound
// because the server only lets you inspect a player you cannot attack, i.e. your own faction.
// Without a player pushed, every title on this page reads NONE (found by this test).
s.set_unit("player", Some(alliance_player()));
// 4 yards away (d² = 16), inside the verified `CanInspect` 100.0 — the window refuses to open
// otherwise, and a silently-refused open would read as a broken pane.
s.set_inspect_reach(HashMap::from([("target".to_string(), 16.0)]));
s.set_inspect_honor(Some(inspect_reply()));
s.run(r#"InspectUnit("target")"#).unwrap();
s.run(r#"ToggleInspect("BenillaInspectHonorFrame")"#)
.unwrap();
s.resolve();
s
}
/// The twelve-value tuple lands in the twelve slots, and the rank block reads the INSPECTED unit —
/// not the player. Everything the character page's row test pins, on the other feed.
#[test]
fn the_inspect_page_paints_the_reply_it_holds() {
let mut s = shown_inspect_honor_page();
for (frame, want) in [
("BenillaInspectHonorFrameCurrentHKValue", "17"),
("BenillaInspectHonorFrameCurrentDKValue", "2"),
("BenillaInspectHonorFrameYesterdayHKValue", "41"),
("BenillaInspectHonorFrameYesterdayContributionValue", "640"),
("BenillaInspectHonorFrameThisWeekHKValue", "123"),
("BenillaInspectHonorFrameThisWeekContributionValue", "1250"),
("BenillaInspectHonorFrameLastWeekHKValue", "420"),
("BenillaInspectHonorFrameLastWeekContributionValue", "8431"),
("BenillaInspectHonorFrameLastWeekStandingValue", "57"),
("BenillaInspectHonorFrameLifeTimeHKValue", "3907"),
("BenillaInspectHonorFrameLifeTimeDKValue", "12"),
(
"BenillaInspectHonorFrameLifeTimeRankValue",
"Lieutenant Commander",
),
] {
assert_eq!(text(&mut s, frame), want, "{frame}");
}
// The rank block: the target's CURRENT rank (12 → "Knight-Captain", badge 08), and the bar off
// the reply's own byte rather than the player's.
assert_eq!(
text(&mut s, "BenillaInspectHonorFrameCurrentPVPTitle"),
"Knight-Captain"
);
assert_eq!(
text(&mut s, "BenillaInspectHonorFrameCurrentPVPRank"),
"(Rank 8)"
);
let bar = s
.eval::<f64>("return BenillaInspectHonorFrameProgressBar:GetValue()")
.unwrap();
assert!(
(bar - 191.0 / 255.0).abs() < 1e-6,
"the bar reads the REPLY's rank byte, got {bar}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The latch the reference's `OnShow` gates on: with nothing held the page asks and paints nothing;
/// with a reply held it paints and asks for nothing.
///
/// This is the one behaviour on this page that is a *round trip* rather than a read, and getting it
/// backwards is invisible on screen in the common case — a window opened on a player whose data
/// happens to be held looks identical either way.
#[test]
fn the_page_asks_only_when_it_holds_nothing() {
let mut s = shown_inspect_honor_page();
// Held: the open above consumed no request.
assert_eq!(
s.take_inspect_honor_requests(),
0,
"a page holding a reply must not re-ask"
);
// Cleared — the app drops the reply when the inspected player changes. Re-showing the page
// asks, and paints nothing until an answer lands.
s.set_inspect_honor(None);
s.run(r#"BenillaInspectHonorFrame_OnShow()"#).unwrap();
s.resolve();
assert_eq!(
s.take_inspect_honor_requests(),
1,
"a page holding nothing must ask exactly once"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -117,6 +117,10 @@ fn armed() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
load_xml(&s, "HonorFrame.xml");
load_xml(&s, "InspectFrame.xml");
s.set_unit("target", Some(target_unit()));
s.set_inspect(Some(inspect_view("target")));
@@ -132,6 +136,10 @@ fn shipped_inspect_frame_loads_clean() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
load_xml(&s, "HonorFrame.xml");
load_xml(&s, "InspectFrame.xml");
// All 19 slots exist and carry their GetInventorySlotInfo id (1..=19, no ammo slot).
for (name, id) in [
@@ -396,7 +404,7 @@ fn shipped_inspect_frame_drives_end_to_end() {
/// The tab chrome, and where the ref's "toggle the showing page shut" branch actually lives.
///
/// `PanelTemplates_SelectTab` **disables** the selected tab button (`UiPanels.xml`, the ref's own
/// UIPanelTemplates), so on a one-tab window the tab is inert from the moment it opens — clicking it
/// UIPanelTemplates), so the SELECTED tab is inert from the moment it opens — clicking it
/// cannot close anything. `ToggleInspect`'s close-when-already-showing branch
/// (`Blizzard_InspectUI.lua:67-83`) is therefore reachable only by calling `ToggleInspect` directly,
/// which is how a keybinding would. Both halves asserted, because the first one is what makes the
+3
View File
@@ -834,6 +834,9 @@ mod skills_frame_tests;
#[cfg(test)]
mod reputation_frame_tests;
#[cfg(test)]
mod honor_frame_tests;
#[cfg(test)]
mod pet_paperdoll_tests;
+20 -1
View File
@@ -561,6 +561,17 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8)
// 0633's byte-level `[+0xe68]+8` bit-7). Zero on creatures, which have no PLAYER_FLAGS —
// and `UnitIsPVPFreeForAll` is false for them in the reference too.
is_pvp_ffa: store.0.player_flags() & 0x80 != 0,
// The unit's CURRENT honor rank (decision 1512): `PLAYER_BYTES_3` byte 3, on the internal
// 0..=18 scale. It sits in `snapshot` — not in the caller's guid-keyed enrichment like
// `faction_group` — because it needs nothing but the descriptor, and because it is PUBLIC:
// the server streams it for every player in view, which is the only reason the inspect
// pane's `UnitPVPRank("target")` can answer at all. A creature has no PLAYER block and
// reads 0, the reference's own answer for one.
pvp_rank: store.0.player_pvp_rank().unwrap_or(0),
// `PLAYER_BYTES_3` byte 2 — the city-protector title, the same PUBLIC dword as the rank
// byte above. `UnitPVPName` appends a `PVP_MEDAL<n>` line for a non-zero one; 0 is "no
// medal", which is every character on this server (vmangos never writes the byte).
pvp_medal: store.0.player_pvp_medal().unwrap_or(0),
// is_player + the creature-record extras (subtitle/type/rank/civilian) are the caller's
// guid-keyed enrichment — [`enrich_unit`].
..Default::default()
@@ -918,13 +929,21 @@ fn feed_units(
// art that ships is Alliance/Horde/FFA), and it is indistinguishable on screen from the icon
// being broken, which has now cost two separate sessions an investigation. So it is a BENCH
// diagnostic, not UI: nothing appears on screen, exactly as in the reference.
//
// The honor arc (1512) put a SECOND surface behind this same side: a rank's title is the
// GlobalString `PVP_RANK_<rank>_<team>`, and with no side there is no team digit, so
// `GetPVPRankInfo` answers nil and the Honor tab renders `NONE` at every rank — for a Grand
// Marshal. That reads exactly like an unbuilt pane, which is why it is named in the warning
// rather than left for the next investigation to rediscover.
if let Some(p) = &player {
let sideless = p.faction_group.is_none();
if sideless && !feed.warned_sideless {
warn!(
"faction: our own template names no side (usually GM mode — vmangos forces \
template 35, group mask 0). Faction-derived UI cannot resolve a side while this \
holds: the PvP flag icon stays hidden however flagged you are. `.gm off` restores it."
holds: the PvP flag icon stays hidden however flagged you are, and the Honor tab's rank \
title reads NONE at every rank (PVP_RANK_<rank>_<team> has no team digit). \
`.gm off` restores both."
);
}
feed.warned_sideless = sideless;
+15 -6
View File
@@ -13,12 +13,13 @@ use crate::messages::{
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems,
QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask,
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog,
PetMode, PetSpells, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss,
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
WhoResults, XpGain,
};
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
@@ -1028,6 +1029,14 @@ pub enum SessionEvent {
},
/// Start the duel countdown (`SMSG_DUEL_COUNTDOWN`) — already in whole seconds.
DuelCountdown { seconds: u32 },
/// Another player's honor stats came back (`MSG_INSPECT_HONOR_STATS` reply, decision 1512) —
/// the inspect window's Honor tab. Arrives only in answer to our own ask, and a *refused* ask
/// is answered with silence (the server returns without sending), so a consumer must not
/// treat "no event yet" as "the data is coming".
InspectHonorStats(InspectHonorStats),
/// A kill paid out honor (`SMSG_PVP_CREDIT`) — the honor-gain chat line and floating combat
/// text. Also fires for a **dishonorable** kill, where `honor` is negative.
PvpCredit(PvpCredit),
/// One mirror timer started, or was wholly re-stated (`SMSG_START_MIRROR_TIMER`, decision
/// 0874) — the breath / fatigue bars. There is no separate update opcode: the server re-sends
/// this whole packet whenever direction, remaining time or frozen state changes, so a
@@ -486,6 +486,8 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
loser,
}],
ServerPacket::DuelCountdown { seconds } => vec![SessionEvent::DuelCountdown { seconds }],
ServerPacket::InspectHonorStats(stats) => vec![SessionEvent::InspectHonorStats(stats)],
ServerPacket::PvpCredit(credit) => vec![SessionEvent::PvpCredit(credit)],
ServerPacket::MirrorTimerStart(start) => vec![SessionEvent::MirrorTimerStart(start)],
ServerPacket::MirrorTimerPause { kind, paused } => {
vec![SessionEvent::MirrorTimerPause { kind, paused }]
@@ -45,6 +45,7 @@ mod parse;
mod pet;
mod pose;
mod progression;
mod pvp;
mod quest;
mod reputation;
mod roster;
@@ -159,6 +160,7 @@ pub use pet::{
};
pub use pose::{set_sheathed, stand_state_change};
pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain};
pub use pvp::{inspect_honor_stats, InspectHonorStats, PvpCredit};
pub use quest::{
dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward,
questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward,
@@ -254,6 +254,11 @@ pub const SMSG_LEVELUP_INFO: u16 = 0x01D4; // 468
/// A newly explored area — area id + the XP it granted (0 at max level). Body
/// in [`super::progression::read_exploration_xp`] — decision 0828.
pub const SMSG_EXPLORATION_EXPERIENCE: u16 = 0x01F8; // 504 (VERIFIED vmangos `Opcodes_1_12_1.h:505`)
/// The honor payout (VERIFIED vmangos `Opcodes_1_12_1.h`: 652) — the XP-gain line's PvP twin, and
/// the only attributed notice of an honor award: the descriptor's contribution fields move too,
/// but they are running totals. Sent for **dishonorable** kills as well, carrying negative honor.
/// Body in [`super::pvp::read_pvp_credit`] ([`super::PvpCredit`]) — decision 1512.
pub const SMSG_PVP_CREDIT: u16 = 0x028C; // 652
// The combat-log wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 464/587/590/591/592) — decision 0137
// phase 2's floating-combat-text data feed. Bodies in [`super::spells`].
@@ -471,6 +476,16 @@ pub const CMSG_CANCEL_CHANNELLING: u16 = 0x013B; // 315
/// streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields without waiting on it, and no FrameXML handler
/// registers an inspect event. Decision 0631.
pub const CMSG_INSPECT: u16 = 0x0114; // 276
/// 726 (VERIFIED vmangos `Opcodes_1_12_1.h`) — the inspect window's **Honor tab**, and an `MSG_`:
/// the one opcode number carries both directions. Our request is a raw 8-byte guid
/// ([`super::inspect_honor_stats`]); the server's reply, on the same number, is the 50-byte
/// [`super::InspectHonorStats`] body. Nothing needs to disambiguate the two shapes — direction
/// does: [`super::parse_server`] only ever sees inbound bodies, so a 0x2D6 there is always the
/// reply, and our outbound body never passes through it.
///
/// Same three silent refusals as [`CMSG_INSPECT`] (`MiscHandler.cpp:962-972`), but *unlike* it
/// this handler does not set our selection. Decision 1512.
pub const MSG_INSPECT_HONOR_STATS: u16 = 0x02D6; // 726
pub const CMSG_SET_SELECTION: u16 = 0x013D; // 317
pub const CMSG_ATTACKSWING: u16 = 0x0141; // 321
pub const CMSG_ATTACKSTOP: u16 = 0x0142; // 322
+18 -7
View File
@@ -9,13 +9,14 @@ use super::{
CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo,
GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse,
GuildRoster, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed,
LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode,
Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails,
QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog,
SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus,
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption,
QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets,
SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss,
SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose,
VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -983,6 +984,14 @@ pub enum ServerPacket {
DuelCountdown {
seconds: u32,
},
/// The `MSG_INSPECT_HONOR_STATS` **reply** — another player's honor stats (decision 1512).
/// The opcode is an `MSG_`, so the same number carries our request; direction disambiguates
/// (see [`super::opcode::MSG_INSPECT_HONOR_STATS`]). Unsolicited replies do not happen: this
/// only ever arrives because we asked, and a refused ask is answered with silence.
InspectHonorStats(InspectHonorStats),
/// `SMSG_PVP_CREDIT` — an honor payout, the only *attributed* notice of one. Also sent for
/// dishonorable kills, with negative honor.
PvpCredit(PvpCredit),
/// `SMSG_START_MIRROR_TIMER` — start or wholly re-state one mirror timer (the breath /
/// fatigue bars, decision 0874). The family has no update opcode: every change re-sends this.
MirrorTimerStart(MirrorTimerStart),
@@ -1351,6 +1360,8 @@ impl ServerPacket {
ServerPacket::DuelComplete { .. } => "SMSG_DUEL_COMPLETE".into(),
ServerPacket::DuelWinner { .. } => "SMSG_DUEL_WINNER".into(),
ServerPacket::DuelCountdown { .. } => "SMSG_DUEL_COUNTDOWN".into(),
ServerPacket::InspectHonorStats(..) => "MSG_INSPECT_HONOR_STATS".into(),
ServerPacket::PvpCredit(..) => "SMSG_PVP_CREDIT".into(),
ServerPacket::MirrorTimerStart(..) => "SMSG_START_MIRROR_TIMER".into(),
ServerPacket::MirrorTimerPause { .. } => "SMSG_PAUSE_MIRROR_TIMER".into(),
ServerPacket::MirrorTimerStop { .. } => "SMSG_STOP_MIRROR_TIMER".into(),
+13 -3
View File
@@ -13,9 +13,9 @@ use crate::wire::{
use super::{
action_bar, area_trigger, attack, bank, binder, channel, chat, combat_log, death, duel,
gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move, movement,
opcode, page_text, pet, progression, quest, social, spellbook, spells, taxi, trade, trainer,
update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket,
SpeedKind,
opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, taxi, trade,
trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode,
ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -964,6 +964,16 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_DUEL_COUNTDOWN => ServerPacket::DuelCountdown {
seconds: duel::read_duel_countdown(&mut r)?,
},
// The honor pair (decision 1512; bodies in `pvp`). MSG_INSPECT_HONOR_STATS is an `MSG_`:
// opcode 0x2D6 carries our 8-byte request AND the server's 50-byte reply. There is no
// ambiguity to resolve here — `parse_server` is fed only by the world *reader*, so an
// 0x2D6 body arriving at this function is always the reply. The request shape is built by
// `pvp::inspect_honor_stats` and goes straight out through `world::writer::pvp`; it never
// reaches this match.
opcode::MSG_INSPECT_HONOR_STATS => {
ServerPacket::InspectHonorStats(pvp::read_inspect_honor_stats(&mut r)?)
}
opcode::SMSG_PVP_CREDIT => ServerPacket::PvpCredit(pvp::read_pvp_credit(&mut r)?),
// The mirror timers (decision 0874; bodies in `mirror_timer`). START carries the timer's
// whole state and is re-sent on every change — there is no update opcode in the family.
opcode::SMSG_START_MIRROR_TIMER => {
+368
View File
@@ -0,0 +1,368 @@
//! The two honor-system wire messages (decision 1512): the inspect-honor request/reply pair and
//! the honor-gain credit. Every shape below is VERIFIED against vmangos source (file:line cites
//! per item); the goldens are hand-built against the server's own `AppendBodyTo` order.
//!
//! The *durable* honor state is not here — it rides the player descriptor, eleven PRIVATE fields
//! plus the PUBLIC current-rank byte ([`crate::messages::ObjectFields::player_pvp_rank`] and
//! friends). These two packets cover exactly what the descriptor cannot: **somebody else's**
//! honor stats (which never stream to us), and the *moment* a kill pays out.
//!
//! Not modelled here (known siblings, VERIFIED present in vmangos `Opcodes_1_12_1.h`): the
//! battleground family — `MSG_PVP_LOG_DATA` (736), `CMSG`/`SMSG_BATTLEFIELD_LIST` (572/573) and
//! `…_STATUS` (723/724) — which 0208/0631 defer wholesale, and `SMSG_ZONE_UNDER_ATTACK` (596), a
//! world broadcast rather than an honor message.
use std::io;
use crate::wire::{read_i32_le, read_u16_le, read_u32_le, read_u64_le, read_u8};
/// Another player's honor stats — the `MSG_INSPECT_HONOR_STATS` **reply** (opcode `0x2D6`/726,
/// VERIFIED vmangos `Opcodes_1_12_1.h`; already named in `opcode_names`). A 50-byte body, all
/// little-endian with no padding, in the exact order
/// `WorldPackets::Misc::InspectHonorStatsResponse::AppendBodyTo` writes it
/// (`Server/Packets/Misc.cpp:301-325`). Every `#if SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`
/// block in that function is live for build 5875, so all three conditional members are present.
///
/// This exists because the honor descriptor fields are PRIVATE: nothing about a *foreign* player's
/// kills, honor or progress bar is on the wire until we ask. The one exception is their current
/// rank, which streams publicly in `PLAYER_BYTES_3` and so is **not** in this packet —
/// [`crate::messages::ObjectFields::player_pvp_rank`] answers it off the target's own descriptor.
///
/// The server builds every value straight out of the target's descriptor
/// (`Handlers/MiscHandler.cpp:962-1010`), so these fields are the inspected player's own
/// `PLAYER_FIELD_*` honor block, re-serialized — with two shape differences worth knowing:
/// `sessionKills` arrives as the raw packed dword rather than a split pair, and the three weekly
/// HK counts arrive as bare `u16`s with a separate (dead) dishonorable slot beside each.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InspectHonorStats {
/// Whose stats these are — echoed from our request (`MiscHandler.cpp:977`). The reply carries
/// no error shape at all: the server simply does not answer when the target is missing, out of
/// `INSPECT_DISTANCE` (10 yd), or a valid attack target, so a silent non-reply is the refusal.
pub player_guid: u64,
/// The target's **highest lifetime** honor rank (`GetHighestRank().rank`,
/// `MiscHandler.cpp:985`) — the *internal* rank 0..18, the same domain as
/// [`crate::messages::ObjectFields::player_honor_rank`], NOT the visual number the UI draws.
/// Their *current* rank is the PUBLIC descriptor byte, not this.
pub highest_rank: u8,
/// Today's kills as the raw packed `PLAYER_FIELD_SESSION_KILLS` dword — low `u16` honorable,
/// high `u16` dishonorable (`MiscHandler.cpp:988` reads the whole field with
/// `GetUInt32Value`). Split with [`InspectHonorStats::session_kills`].
pub session_kills: u32,
/// Yesterday's honorable kills (`GetUInt16Value(PLAYER_FIELD_YESTERDAY_KILLS, 0)`).
pub yesterday_hk: u16,
/// Written `0` by vmangos (`MiscHandler.cpp:994`, comment *"Unknown (deprecated, yesterday
/// dishonourable?)"*). Decoded and carried rather than skipped: whether the real client reads
/// these three slots at all is a question currently out with an RE orchestrator in
/// `wow-5875-re`, and a field we decode and ignore is honest where a silently-skipped one
/// would be a landmine if the verdict comes back "it reads them".
pub unknown_old1: u16,
/// Last week's honorable kills.
pub last_week_hk: u16,
/// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`].
pub unknown_old2: u16,
/// This week's honorable kills (a 1.6.0 addition; live for 5875).
pub this_week_hk: u16,
/// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`].
pub unknown_old3: u16,
/// Lifetime honorable kills (`PLAYER_FIELD_LIFETIME_HONORBALE_KILLS`, vmangos's spelling).
pub lifetime_hk: u32,
/// Lifetime dishonorable kills.
pub lifetime_dhk: u32,
/// Yesterday's honor points (`PLAYER_FIELD_YESTERDAY_CONTRIBUTION`).
pub yesterday_honor: u32,
/// Last week's honor points.
pub last_week_honor: u32,
/// This week's honor points (a 1.6.0 addition; live for 5875).
pub this_week_honor: u32,
/// Last week's **standing** — the numeric ladder position, not a rank
/// (`PLAYER_FIELD_LAST_WEEK_RANK`; `0` = unranked that week).
pub last_week_rank: u32,
/// The rank progress bar, `0..255` **within the target's current rank** (a 1.6.0 addition;
/// live for 5875). See [`crate::messages::ObjectFields::player_honor_rank_bar`] for the
/// server's computation and the negative-rank wrap.
pub rank_bar: u8,
}
impl InspectHonorStats {
/// [`Self::session_kills`] split into `(honorable, dishonorable)` — the `TWO_SHORT` packing
/// the descriptor field uses (low half, then high half). The wire carries the dword whole
/// here, so unlike the descriptor accessor this split is ours to do.
pub fn session_kills(&self) -> (u16, u16) {
(self.session_kills as u16, (self.session_kills >> 16) as u16)
}
}
/// Read the `MSG_INSPECT_HONOR_STATS` **reply** (see [`InspectHonorStats`]): 50 bytes in
/// `AppendBodyTo` order. A short body errors (`UnexpectedEof`) rather than yielding a
/// half-populated struct.
///
/// The opcode is an **`MSG_`**: the same number 0x2D6 carries our request *and* the server's
/// reply, and the two bodies are different shapes (8 bytes vs 50). Nothing has to disambiguate
/// them, because direction does it: [`super::parse_server`] is fed only by the world *reader*, so
/// every 0x2D6 body it ever sees is a reply. Our own request never passes through here — it is
/// built by [`inspect_honor_stats`] and handed straight to the writer.
pub(super) fn read_inspect_honor_stats(r: &mut &[u8]) -> io::Result<InspectHonorStats> {
Ok(InspectHonorStats {
player_guid: read_u64_le(r)?,
highest_rank: read_u8(r)?,
session_kills: read_u32_le(r)?,
yesterday_hk: read_u16_le(r)?,
unknown_old1: read_u16_le(r)?,
last_week_hk: read_u16_le(r)?,
unknown_old2: read_u16_le(r)?,
this_week_hk: read_u16_le(r)?,
unknown_old3: read_u16_le(r)?,
lifetime_hk: read_u32_le(r)?,
lifetime_dhk: read_u32_le(r)?,
yesterday_honor: read_u32_le(r)?,
last_week_honor: read_u32_le(r)?,
this_week_honor: read_u32_le(r)?,
last_week_rank: read_u32_le(r)?,
rank_bar: read_u8(r)?,
})
}
/// Body of the `MSG_INSPECT_HONOR_STATS` **request**: the inspected player's full 8-byte GUID and
/// nothing else (VERIFIED vmangos `WorldPackets::Misc::InspectHonorStats::ReadFromWorldPacket`,
/// `Server/Packets/Misc.cpp:93-96` — `recv_data >> guid`, a raw `uint64`, not a packed guid).
///
/// Server gates (`MiscHandler.cpp:962-972`) are the same three `CMSG_INSPECT` applies: the target
/// must be an online player, within `INSPECT_DISTANCE` (10.0), and not a valid attack target. Each
/// refusal is a silent `return` — there is no error reply, so a consumer must not block on an
/// answer that may never come. Unlike `CMSG_INSPECT`, this handler does **not** set our selection
/// (`HandleInspectOpcode` opens with `SetSelectionGuid`; this one does not), which is why the send
/// lives in `world::writer::pvp` rather than `writer::selection`.
pub fn inspect_honor_stats(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
/// An honor payout (`SMSG_PVP_CREDIT`, opcode `0x28C`/652 — VERIFIED vmangos
/// `Opcodes_1_12_1.h`; already named in `opcode_names`). A 16-byte body in the order
/// `WorldPackets::Misc::PvpCredit::AppendBodyTo` writes it (`Server/Packets/Misc.cpp:376-381`),
/// sent from `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1061-1093`) on every honor row the server
/// books — **including a dishonorable one**, which is why [`PvpCredit::honor`] is signed.
///
/// This is the *only* notice of a specific payout: the descriptor's contribution fields move too,
/// but they are running totals with no attribution. The chat line and the floating combat text
/// both have to come from here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PvpCredit {
/// Honor points, truncated to an integer server-side (`static_cast<int32>(honor)`).
///
/// **Signed, and genuinely negative for a dishonorable kill**: `HonorMgr::Add` computes
/// `honor = (type == DISHONORABLE) ? -cp : cp` (`HonorMgr.cpp:807`) and hands *that* to
/// `SendPVPCredit` (`HonorMgr.cpp:833`). A `u32` here would read a DK penalty as ~4 billion
/// honor.
pub honor: i32,
/// Who we killed. **`0` means there is no victim** — vmangos only fills the guid inside
/// `if (victim)` (`HonorMgr.cpp:1069-1071`), and it passes the *pre-fallback* `realSource`, so
/// an honor row booked with no source at all arrives with a zero guid. The UI needs a
/// victim-less phrasing rather than resolving a name for guid 0.
pub victim_guid: u64,
/// The victim's **internal** rank — the same `[0..18]` domain as the descriptor's rank bytes
/// and [`InspectHonorStats::highest_rank`], and therefore a direct index into the
/// `PVP_RANK_<rank>_<team>` GlobalStrings. NOT the visual rank the badge texture uses; do the
/// `internal > 4 ? internal - 4 : -internal` conversion (`HonorMgr.cpp:991`) at the display
/// edge if a badge is wanted, and do not do it twice.
///
/// VERIFIED `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1078-1089`): a **player** victim sends
/// `GetHonorMgr().GetRank().rank`, a **creature** victim sends `19` iff it is a racial leader
/// and otherwise leaves the field at its `0` default.
///
/// One quirk to observe rather than imitate: for a player victim vmangos **floors the value at
/// 5** (`if (!rank) rank = (HONOR_RANK_COUNT - POSITIVE_HONOR_RANK_COUNT) + 1`, i.e. "at least
/// Scout"), with the comment *"Never display just 'HK:' without rank name"*. That is the
/// server working around a client that would otherwise print an empty rank name — it is
/// **server behaviour we receive**, not a rule we should re-implement: we render whatever
/// number arrives, and an unranked-victim `0` from some other server must still come out
/// sanely at the display edge.
///
/// `i32` because that is the wire type; nothing vmangos sends here is negative.
pub victim_rank: i32,
}
/// Read `SMSG_PVP_CREDIT` (see [`PvpCredit`]): `i32 honor`, `u64 victimGuid`, `i32 victimRank`.
pub(super) fn read_pvp_credit(r: &mut &[u8]) -> io::Result<PvpCredit> {
Ok(PvpCredit {
honor: read_i32_le(r)?,
victim_guid: read_u64_le(r)?,
victim_rank: read_i32_le(r)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The 50-byte reply, hand-built in `InspectHonorStatsResponse::AppendBodyTo` order with the
/// field boundaries commented — a level-60 target with a rank-11 lifetime best, kills in every
/// bucket, and a bar three-quarters of the way through the current rank.
#[test]
fn inspect_honor_stats_golden() {
#[rustfmt::skip]
let body: [u8; 50] = [
// [0..8) u64 playerGuid = 0x0000_0001_0000_2AB3
0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
// [8] u8 highestRank = 11 (internal)
0x0B,
// [9..13) u32 sessionKills = 0x0002_0011 — 17 HK low, 2 DK high
0x11, 0x00, 0x02, 0x00,
// [13..15) u16 yesterdayHK = 41
0x29, 0x00,
// [15..17) u16 unknownOld1 = 0 (server always writes 0)
0x00, 0x00,
// [17..19) u16 lastWeekHK = 420
0xA4, 0x01,
// [19..21) u16 unknownOld2 = 0
0x00, 0x00,
// [21..23) u16 thisWeekHK = 123
0x7B, 0x00,
// [23..25) u16 unknownOld3 = 0
0x00, 0x00,
// [25..29) u32 lifetimeHK = 3907
0x43, 0x0F, 0x00, 0x00,
// [29..33) u32 lifetimeDHK = 12
0x0C, 0x00, 0x00, 0x00,
// [33..37) u32 yesterdayHonor = 640
0x80, 0x02, 0x00, 0x00,
// [37..41) u32 lastWeekHonor = 8431
0xEF, 0x20, 0x00, 0x00,
// [41..45) u32 thisWeekHonor = 1250
0xE2, 0x04, 0x00, 0x00,
// [45..49) u32 lastWeekRank = 57 (the STANDING)
0x39, 0x00, 0x00, 0x00,
// [49] u8 rankBar = 191
0xBF,
];
let mut r = body.as_slice();
let stats = read_inspect_honor_stats(&mut r).unwrap();
assert!(r.is_empty(), "the whole 50-byte body is consumed");
assert_eq!(
stats,
InspectHonorStats {
player_guid: 0x0000_0001_0000_2AB3,
highest_rank: 11,
session_kills: 0x0002_0011,
yesterday_hk: 41,
unknown_old1: 0,
last_week_hk: 420,
unknown_old2: 0,
this_week_hk: 123,
unknown_old3: 0,
lifetime_hk: 3_907,
lifetime_dhk: 12,
yesterday_honor: 640,
last_week_honor: 8_431,
this_week_honor: 1_250,
last_week_rank: 57,
rank_bar: 191,
}
);
// The packed today-kills dword splits the same way the descriptor's TWO_SHORT does.
assert_eq!(stats.session_kills(), (17, 2));
}
/// A truncated reply must ERROR, not panic and not yield a half-populated struct — the wire is
/// hostile input. 49 bytes is the nastiest case: everything but the trailing `rankBar`, which
/// is exactly what a pre-1.6.1 server would send.
#[test]
fn inspect_honor_stats_truncated_body_errors() {
let full = [0u8; 50];
for len in [0usize, 8, 13, 49] {
let mut r = &full[..len];
assert!(
read_inspect_honor_stats(&mut r).is_err(),
"a {len}-byte inspect-honor body must be rejected"
);
}
// …and the full length still parses, so the loop above is testing truncation and not a
// decoder that rejects everything.
let mut r = full.as_slice();
assert!(read_inspect_honor_stats(&mut r).is_ok());
}
/// The request is the bare 8-byte guid — byte-exact against
/// `InspectHonorStats::ReadFromWorldPacket`'s single `recv_data >> guid`.
#[test]
fn inspect_honor_stats_request_golden() {
assert_eq!(
inspect_honor_stats(0x0000_0001_0000_2AB3),
vec![0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00],
);
// No sentinel handling: a zero guid is encoded as-is (the server answers by not answering).
assert_eq!(inspect_honor_stats(0), vec![0u8; 8]);
}
/// The 16-byte credit body, hand-built in `PvpCredit::AppendBodyTo` order.
#[test]
fn pvp_credit_golden() {
#[rustfmt::skip]
let body: [u8; 16] = [
// [0..4) i32 honor = 143
0x8F, 0x00, 0x00, 0x00,
// [4..12) u64 victimGuid = 0x0000_0001_0000_2AB3
0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
// [12..16) i32 victimRank = 11 (INTERNAL rank — the PVP_RANK_11_<team> key)
0x0B, 0x00, 0x00, 0x00,
];
let mut r = body.as_slice();
let credit = read_pvp_credit(&mut r).unwrap();
assert!(r.is_empty(), "the whole 16-byte body is consumed");
assert_eq!(
credit,
PvpCredit {
honor: 143,
victim_guid: 0x0000_0001_0000_2AB3,
victim_rank: 11,
}
);
}
/// A **dishonorable** kill rides the same packet with a negative `honor`
/// (`HonorMgr.cpp:807`, `honor = -cp`) — the reason the field is `i32`. Decoded as `u32` this
/// body would read as 4 294 967 291 honor.
#[test]
fn pvp_credit_dishonorable_kill_is_negative_honor() {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&(-5i32).to_le_bytes());
body.extend_from_slice(&0x0000_0001_0000_2AB3u64.to_le_bytes());
body.extend_from_slice(&5i32.to_le_bytes()); // floored to "Scout" server-side
let mut r = body.as_slice();
let credit = read_pvp_credit(&mut r).unwrap();
assert!(r.is_empty());
assert_eq!(credit.honor, -5);
assert_eq!(credit.victim_rank, 5);
}
/// The victim-less shape the UI has to phrase differently: vmangos fills the guid only inside
/// `if (victim)` and passes the pre-fallback `realSource`, so an honor row with no source
/// arrives as a zero guid with the rank left at its `0` default (which is also what a
/// non-racial-leader creature victim sends).
#[test]
fn pvp_credit_zero_guid_means_no_victim() {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&5i32.to_le_bytes());
body.extend_from_slice(&0u64.to_le_bytes()); // no victim
body.extend_from_slice(&0i32.to_le_bytes()); // and so no rank
let mut r = body.as_slice();
let credit = read_pvp_credit(&mut r).unwrap();
assert!(r.is_empty());
assert_eq!(credit.victim_guid, 0, "zero guid = no victim");
assert_eq!(credit.victim_rank, 0);
}
/// A truncated credit body errors rather than panicking.
#[test]
fn pvp_credit_truncated_body_errors() {
let full = [0u8; 16];
for len in [0usize, 4, 12, 15] {
let mut r = &full[..len];
assert!(
read_pvp_credit(&mut r).is_err(),
"a {len}-byte credit body must be rejected"
);
}
let mut r = full.as_slice();
assert!(read_pvp_credit(&mut r).is_ok());
}
}
@@ -261,6 +261,14 @@ const FIELD_PLAYER_BYTES_2: u16 = 194;
// PLAYER_BYTES_3 = UNIT_END(188) + 0x7: the low u16 packs `gender | (drunk & 0xFFFE)` (vmangos
// `Player::SetDrunkValue`), so byte 1 is the drunk value's high byte — exactly the byte the
// reference client reads at `[[unit+0xe68]+0x1d]` (wow-re `drunk_fraction_5e2a90`).
// Byte 2 = the city-protector title (a race id), byte 3 = the **current** honor rank
// (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos `Player.h:351-356`).
//
// **PUBLIC** (VERIFIED vmangos `UpdateFields_1_12_1.h:125`) — and that flag is the whole reason a
// foreign player's rank is knowable: this is the ONE honor value that streams for every visible
// player. The rest of the honor block (1250..1260 below, plus `PLAYER_FIELD_BYTES`' highest-rank
// byte) is PRIVATE, which is why anyone else's honor stats have to be *asked* for over the wire
// (`MSG_INSPECT_HONOR_STATS`, [`crate::messages::InspectHonorStats`]).
const FIELD_PLAYER_BYTES_3: u16 = 195;
// PLAYER_QUEST_LOG_1_1 = UNIT_END(188) + 0xA = 198 (vmangos `UpdateFields_1_12_1.h:128`; this block
// precedes the CONTAINER-onward hex-comment drift — enum arithmetic and hex comment agree here, and
@@ -476,6 +484,50 @@ const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A, BYTES — vmangos
// flags / comboPoints / actionBars / HIGHEST_HONOR_RANK
// (`Player.h` PLAYER_FIELD_BYTES_OFFSET_*).
// The **honor block** — the eleven fields the two PvP/honor panes read, a contiguous run
// UNIT_END(188) + 0x426..0x430 (VERIFIED vmangos `UpdateFields_1_12_1.h:288-298` **enum
// arithmetic**; its inline `// 0x4DC`-style comments carry the usual 6-low drift and are not
// copied). The run needs no fresh anchor of its own: it closes against already-tested neighbours
// at BOTH ends — the 12-slot `BUYBACK_TIMESTAMP_1` array ends exactly where it begins
// (1238 + 12 = 1250) and `WATCHED_FACTION_INDEX`, itself chain-locked to the tested COINAGE, sits
// exactly one past its end (1260 + 1 = 1261). Eleven fields is precisely the size of that gap.
//
// **Every one of them is PRIVATE** — the server sends them only on our OWN descriptor — and that
// is the mechanical reason the character window's Honor tab works for the local player and for
// nobody else. A foreign player's honor stats are not on the wire at all until we ask for them
// with `MSG_INSPECT_HONOR_STATS` ([`crate::messages::InspectHonorStats`]); the sole honor value
// that *does* stream for every visible player is the current rank in the PUBLIC
// `PLAYER_BYTES_3`(195) byte 3 above.
//
// Types, in run order — `+0x426..0x429` TWO_SHORT, `+0x42A..0x42F` INT, `+0x430` BYTES:
//
// The four kill counters are TWO_SHORT, `(honorable, dishonorable)` in the low/high halves. Only
// SESSION_KILLS is genuinely written as two halves (vmangos `HonorMgr.cpp:913-914`,
// `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`); the other three go out as a whole dword
// (`SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`, `HonorMgr.cpp:917/924/929`), so
// their high half is always 0 from this server. The descriptor type is TWO_SHORT regardless and
// the real client reads halves, so we decode halves — see the accessors in `player.rs`.
//
// The six INT fields are the three honor-point "contribution" totals, the two lifetime kill
// counts, and LAST_WEEK_RANK — which is the weekly **standing** (our ladder position), not a rank.
// All six are `SetUInt32Value` writes from `HonorMgr::Update` (`HonorMgr.cpp:917-935`). Our two
// lifetime names fix vmangos's own typo: the header spells them
// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…DISHONORBALE…`.
//
// BYTES2's byte 0 is `HONOR_RANK_BAR` (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos
// `Player.h:366-372`); its bytes 1..3 are a flags byte and two unknowns this server never writes.
const FIELD_PLAYER_FIELD_SESSION_KILLS: u16 = 1250;
const FIELD_PLAYER_FIELD_YESTERDAY_KILLS: u16 = 1251;
const FIELD_PLAYER_FIELD_LAST_WEEK_KILLS: u16 = 1252;
const FIELD_PLAYER_FIELD_THIS_WEEK_KILLS: u16 = 1253;
const FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION: u16 = 1254;
const FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS: u16 = 1255;
const FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS: u16 = 1256;
const FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION: u16 = 1257;
const FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION: u16 = 1258;
const FIELD_PLAYER_FIELD_LAST_WEEK_RANK: u16 = 1259;
const FIELD_PLAYER_FIELD_BYTES2: u16 = 1260;
/// The number of `PLAYER_SKILL_INFO` slots the descriptor reserves (384 fields ÷ 3 per slot;
/// vmangos `UpdateFields_1_12_1.h`). vmangos itself only ever *populates* `PLAYER_MAX_SKILLS` (127,
/// `Player.h:69`) of these — the last reserved slot is never written; `player_skill` honors the
@@ -560,10 +560,148 @@ impl ObjectFields {
/// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's
/// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the
/// values byte at `+0x102b`). `None` before the field streams; rank 0 = never ranked.
///
/// **Not [`Self::player_pvp_rank`]** — that is the *current* rank, a different byte of a
/// different (and PUBLIC) field, and the two diverge the moment a rank is lost: vmangos only
/// ever raises this one (`HonorMgr.cpp:894-895`, `SetHighestRank` iff the new visual rank is
/// strictly greater), so a demoted player keeps their old title here while the current rank
/// falls. The Honor pane shows the current rank; an item requirement reads this one.
pub fn player_honor_rank(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_FIELD_BYTES)
.map(|b| ((b >> 24) & 0xff) as u8)
}
/// `PLAYER_BYTES_3` byte 3 — the player's **current** honor rank (vmangos
/// `PLAYER_BYTES_3_OFFSET_HONOR_RANK`, written by `HonorMgr::Update`, `HonorMgr.cpp:900`).
/// The *internal* rank — `HonorRankInfo::rank`, whose own comment reads "internal range
/// [0..18]": 0 = unranked, 1..4 the negative ranks (Pariah…Dishonored), 5 up the positive ones
/// (Scout/Private…). It indexes the `PVP_RANK_<rank>_<team>` GlobalStrings directly; the
/// number the UI *draws* is the **visual** rank (`rank > 4 ? rank - 4 : -rank`,
/// `HonorMgr.cpp:991`), which is a derivation, not a field — do it at the display edge, not
/// here, and only once.
///
/// **This field is PUBLIC** (vmangos `UpdateFields_1_12_1.h:125`), the only honor value that
/// is: it streams for *every visible player*, not just for us. That is what makes a foreign
/// unit's rank answerable at all — the reference inspect pane's `UnitPVPRank("target")` reads
/// this byte off the target's own descriptor, with no packet involved. Everything else on the
/// Honor tab is PRIVATE and has to be requested
/// ([`crate::messages::read_inspect_honor_stats`]).
///
/// Contrast [`Self::player_honor_rank`], the *highest lifetime* rank in a PRIVATE field's
/// byte 3; [`Self::player_drunk_byte`] reads byte 1 of this same dword. `None` before the
/// field streams.
pub fn player_pvp_rank(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_BYTES_3)
.map(|b| ((b >> 24) & 0xff) as u8)
}
/// `PLAYER_BYTES_3` **byte 2** — the **city-protector title**
/// (`PLAYER_BYTES_3_OFFSET_CITY_PROTECTOR_TITLE`, vmangos `Player.h:354`, whose own comment
/// says the value is a **race id**). PUBLIC, like [`Self::player_pvp_rank`] beside it in the
/// same dword.
///
/// Its one consumer is `UnitPVPName`, which appends `"\n"` plus the `PVP_MEDAL<n>`
/// GlobalString when the byte is non-zero (wow-re `system/ui/scratch/honor-panel-law.md`,
/// `0x609370` leg C) — the "Protector of Stormwind" line under a city defender's name.
///
/// **vmangos never writes it**, so it is 0 for every character on this server and the medal
/// line never renders. Decoded anyway: the field is real, the binding that reads it is real,
/// and a decoder that answered `None` here would make a live divergence look like an unbuilt
/// feature the day a server does write it. `None` before the field streams.
pub fn player_pvp_medal(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_BYTES_3)
.map(|b| ((b >> 16) & 0xff) as u8)
}
/// `PLAYER_FIELD_SESSION_KILLS` — **today's** `(honorable, dishonorable)` kills, low half then
/// high half. The one honor kill counter vmangos genuinely writes as two shorts
/// (`HonorMgr.cpp:913-914`, `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`), so both
/// halves are live here. PRIVATE — self only; `None` before the field streams.
///
/// Despite the name the server recomputes it from *today's* honor-CP rows, not from
/// session start (`HonorMgr::Update`'s `itr.date == today` arm) — the reference's
/// `GetPVPSessionStats()` pair.
pub fn player_session_kills(&self) -> Option<(u16, u16)> {
self.get_u16_pair(FIELD_PLAYER_FIELD_SESSION_KILLS)
}
/// `PLAYER_FIELD_YESTERDAY_KILLS` — `(honorable, dishonorable)` kills for yesterday.
///
/// **The dishonorable half is always `0` from this server, and that is not a decode bug.**
/// The descriptor type really is TWO_SHORT and the real client really does read halves, but
/// vmangos writes the field as a whole dword (`HonorMgr.cpp:917`,
/// `SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`) — so the high half never
/// receives the DK count. We decode the wire faithfully rather than collapsing the field to a
/// `u32`: the shape belongs to the descriptor, and a different server (or a fixed vmangos)
/// would fill it. Same for [`Self::player_last_week_kills`] and
/// [`Self::player_this_week_kills`]. PRIVATE — self only; `None` before the field streams.
pub fn player_yesterday_kills(&self) -> Option<(u16, u16)> {
self.get_u16_pair(FIELD_PLAYER_FIELD_YESTERDAY_KILLS)
}
/// `PLAYER_FIELD_LAST_WEEK_KILLS` — `(honorable, dishonorable)` kills for last week. The
/// high half is always `0` from vmangos (`HonorMgr.cpp:929` writes the whole dword); see
/// [`Self::player_yesterday_kills`]. PRIVATE — self only.
pub fn player_last_week_kills(&self) -> Option<(u16, u16)> {
self.get_u16_pair(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS)
}
/// `PLAYER_FIELD_THIS_WEEK_KILLS` — `(honorable, dishonorable)` kills for the current week.
/// The high half is always `0` from vmangos (`HonorMgr.cpp:924` writes the whole dword); see
/// [`Self::player_yesterday_kills`]. PRIVATE — self only.
pub fn player_this_week_kills(&self) -> Option<(u16, u16)> {
self.get_u16_pair(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS)
}
/// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION` — honor points earned this week (vmangos's
/// "contribution": the summed CP of the week's honor rows, floored at 0 —
/// `HonorMgr.cpp:925`). PRIVATE — self only; `None` before the field streams.
pub fn player_this_week_contribution(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION)
}
/// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` — honor points earned yesterday
/// (`HonorMgr.cpp:918`). PRIVATE — self only.
pub fn player_yesterday_contribution(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION)
}
/// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION` — honor points earned last week
/// (`HonorMgr.cpp:930`). This is the figure the weekly rank calculation actually consumed.
/// PRIVATE — self only.
pub fn player_last_week_contribution(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION)
}
/// `PLAYER_FIELD_LAST_WEEK_RANK` — last week's **standing**, i.e. our numeric position on the
/// server-wide honor ladder for that week (vmangos `m_standing`, `HonorMgr.cpp:931`), NOT an
/// honor rank. `0` means "unranked that week". PRIVATE — self only.
pub fn player_last_week_rank(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_RANK)
}
/// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` (vmangos's spelling) — the lifetime honorable-kill
/// total (`HonorMgr.cpp:934`). PRIVATE — self only; `None` before the field streams.
pub fn player_lifetime_honorable_kills(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS)
}
/// `PLAYER_FIELD_LIFETIME_DISHONORBALE_KILLS` (vmangos's spelling) — the lifetime
/// dishonorable-kill total (`HonorMgr.cpp:935`). PRIVATE — self only.
pub fn player_lifetime_dishonorable_kills(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS)
}
/// `PLAYER_FIELD_BYTES2` byte 0 — the **honor rank progress bar**
/// (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos `HonorMgr.cpp:905-909`):
///
/// ```text
/// honorBar = uint32(|rankPoints|);
/// honorBar = uint8(((honorBar - rank.minRP) / (rank.maxRP - rank.minRP))
/// * (rank.positive ? 255 : -255));
/// ```
///
/// so the byte is **how far through the CURRENT rank we are, scaled 0..255** — not an absolute
/// honor total, and it resets each time the rank changes (`minRP`/`maxRP` are that rank's own
/// bounds, `HonorMgr::CalculateRankInfo`). A *negative* rank multiplies by `-255`, and the
/// C++ cast of a negative float to `uint8` wraps rather than clamping, so the byte a demoted
/// player carries is the wrapped product. We carry the byte the wire sent; interpreting the
/// negative-rank case is the display edge's problem, not the decoder's.
///
/// The field only exists from client 1.6.1 on (vmangos guards the write with
/// `SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`), which is live for build 5875. PRIVATE —
/// self only; `None` before the field streams.
pub fn player_honor_rank_bar(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_FIELD_BYTES2)
.map(|b| (b & 0xff) as u8)
}
/// `UNIT_FIELD_BYTES_0` byte 0 — the unit's race. `None` when the field wasn't sent.
pub fn unit_race(&self) -> Option<u8> {
self.unit_bytes_0().map(|b| (b & 0xff) as u8)
@@ -96,6 +96,40 @@ fn combat_stat_indices_chain_to_the_tested_anchors() {
FIELD_PLAYER_CHARACTER_POINTS2 + 1
);
assert_eq!(FIELD_PLAYER_TRACK_RESOURCES, UNIT_END + 0x395);
// The honor block (decision 1512) is eleven contiguous fields filling EXACTLY the gap between
// two already-anchored neighbours — the 12-slot buyback-timestamp array below it and the
// watched-faction slot above it — so it needs no anchor of its own. If any of the three ever
// drifts, one of these two closures breaks.
assert_eq!(
FIELD_PLAYER_BUYBACK_TIMESTAMP_1 + 12,
FIELD_PLAYER_FIELD_SESSION_KILLS,
"the honor block opens where the 12-slot buyback-timestamp array ends"
);
assert_eq!(
FIELD_PLAYER_FIELD_BYTES2 + 1,
FIELD_PLAYER_WATCHED_FACTION_INDEX,
"and closes one below the watched-faction slot"
);
assert_eq!(FIELD_PLAYER_FIELD_SESSION_KILLS, UNIT_END + 0x426);
assert_eq!(FIELD_PLAYER_FIELD_YESTERDAY_KILLS, UNIT_END + 0x427);
assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS, UNIT_END + 0x428);
assert_eq!(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS, UNIT_END + 0x429);
assert_eq!(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION, UNIT_END + 0x42A);
assert_eq!(
FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS,
UNIT_END + 0x42B
);
assert_eq!(
FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS,
UNIT_END + 0x42C
);
assert_eq!(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION, UNIT_END + 0x42D);
assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION, UNIT_END + 0x42E);
assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_RANK, UNIT_END + 0x42F);
assert_eq!(FIELD_PLAYER_FIELD_BYTES2, UNIT_END + 0x430);
// The CURRENT rank rides the appearance dword instead, nowhere near that block — which is the
// whole point: PLAYER_BYTES_3 is PUBLIC, so it is the one honor value a foreign player streams.
assert_eq!(FIELD_PLAYER_BYTES_3, UNIT_END + 0x7);
}
#[test]
@@ -272,6 +306,103 @@ fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() {
assert_eq!(ObjectFields::default().player_combo_points(), None);
}
/// `PLAYER_BYTES_3` byte 3 is the **current** honor rank, and it shares its dword with the
/// gender+inebriation low u16 — so the fixture carries a non-zero drunk byte on purpose: a wrong
/// shift would read `0xA0` (160) as the rank and pass a byte-3-is-zero test.
///
/// The pairing with `PLAYER_FIELD_BYTES`' byte 3 is the confusable one and the reason both are
/// asserted here: current rank (PUBLIC, field 195) vs highest lifetime rank (PRIVATE, field 1222).
/// A fixture carrying only 195 must answer `None` for the highest rank, not `0`.
#[test]
fn player_bytes_3_byte_3_is_the_current_pvp_rank_not_the_highest() {
// byte 0 = 0x01 gender, byte 1 = 0xA0 drunk, byte 2 = 0x04 city-protector title,
// byte 3 = 0x0B = internal rank 11 (visual 7).
let f = ObjectFields::from_pairs(&[(195, 0x0B_04_A0_01)]);
assert_eq!(f.player_pvp_rank(), Some(11));
assert_eq!(f.player_drunk_byte(), Some(0xA0), "the neighbouring byte");
assert_eq!(
f.player_honor_rank(),
None,
"the HIGHEST rank is a different field (1222) — absent means absent"
);
// The two really do diverge: a demoted player keeps the higher lifetime rank.
let demoted = ObjectFields::from_pairs(&[(195, 0x03_00_00_00), (1222, 0x0B_00_00_00)]);
assert_eq!(demoted.player_pvp_rank(), Some(3));
assert_eq!(demoted.player_honor_rank(), Some(11));
assert_eq!(ObjectFields::default().player_pvp_rank(), None);
}
/// The four TWO_SHORT kill counters split low = honorable / high = dishonorable
/// (`MAKE_PAIR32`; vmangos writes SESSION_KILLS as two `SetUInt16Value` halves). The other three
/// come off vmangos as a whole dword, so their high half is 0 — asserted, because "DK reads 0" is
/// a property of *this server*, not of the field, and a future capture that shows otherwise is a
/// finding rather than a regression.
#[test]
fn honor_kill_counters_split_into_honorable_and_dishonorable_halves() {
let f = ObjectFields::from_pairs(&[
(1250, 0x0003_0011), // SESSION: 17 HK, 3 DK — both halves live
(1251, 0x0000_0029), // YESTERDAY: 41 HK, whole-dword write ⇒ DK half 0
(1252, 0x0000_01A4), // LAST_WEEK: 420 HK
(1253, 0x0000_007B), // THIS_WEEK: 123 HK
]);
assert_eq!(f.player_session_kills(), Some((17, 3)));
assert_eq!(f.player_yesterday_kills(), Some((41, 0)));
assert_eq!(f.player_last_week_kills(), Some((420, 0)));
assert_eq!(f.player_this_week_kills(), Some((123, 0)));
// A value that fills the high half of one of the dword-written fields still decodes as a
// pair — we carry the descriptor's shape, not vmangos's habit.
let both = ObjectFields::from_pairs(&[(1251, 0x0007_0029)]);
assert_eq!(both.player_yesterday_kills(), Some((41, 7)));
// Absent stays absent for every one of the four.
let empty = ObjectFields::default();
assert_eq!(empty.player_session_kills(), None);
assert_eq!(empty.player_yesterday_kills(), None);
assert_eq!(empty.player_last_week_kills(), None);
assert_eq!(empty.player_this_week_kills(), None);
}
/// The INT half of the honor block — three contribution totals, the weekly standing, and the two
/// lifetime counters — each reading its own index (a swapped constant shows up here).
#[test]
fn honor_contributions_standing_and_lifetime_read_their_own_fields() {
let f = ObjectFields::from_pairs(&[
(1254, 1_250), // THIS_WEEK_CONTRIBUTION
(1255, 3_907), // LIFETIME_HONORABLE_KILLS
(1256, 12), // LIFETIME_DISHONORABLE_KILLS
(1257, 640), // YESTERDAY_CONTRIBUTION
(1258, 8_431), // LAST_WEEK_CONTRIBUTION
(1259, 57), // LAST_WEEK_RANK — the STANDING, not an honor rank
]);
assert_eq!(f.player_this_week_contribution(), Some(1_250));
assert_eq!(f.player_lifetime_honorable_kills(), Some(3_907));
assert_eq!(f.player_lifetime_dishonorable_kills(), Some(12));
assert_eq!(f.player_yesterday_contribution(), Some(640));
assert_eq!(f.player_last_week_contribution(), Some(8_431));
assert_eq!(f.player_last_week_rank(), Some(57));
let empty = ObjectFields::default();
assert_eq!(empty.player_this_week_contribution(), None);
assert_eq!(empty.player_yesterday_contribution(), None);
assert_eq!(empty.player_last_week_contribution(), None);
assert_eq!(empty.player_last_week_rank(), None);
assert_eq!(empty.player_lifetime_honorable_kills(), None);
assert_eq!(empty.player_lifetime_dishonorable_kills(), None);
}
/// `PLAYER_FIELD_BYTES2` byte 0 is the rank progress bar; bytes 1..3 are a flags byte and two
/// unknowns, so the fixture sets them to catch a wrong shift.
#[test]
fn player_honor_rank_bar_is_byte_zero_of_player_field_bytes2() {
let f = ObjectFields::from_pairs(&[(1260, 0xDE_AD_BE_7F)]);
assert_eq!(f.player_honor_rank_bar(), Some(0x7F));
// A negative rank's `uint8(fraction * -255)` wraps rather than clamping — we carry the byte
// the wire sent and leave the interpretation to the display edge.
assert_eq!(
ObjectFields::from_pairs(&[(1260, 0x0000_009C)]).player_honor_rank_bar(),
Some(0x9C)
);
assert_eq!(ObjectFields::default().player_honor_rank_bar(), None);
}
/// `PLAYER_FIELD_COMBO_TARGET` reads as one GUID out of its two dwords — the unit the points are
/// banked on, which the display gate compares against the current target (decision 0875).
#[test]
@@ -15,6 +15,7 @@
//! | [`self_movement`] | our own mover: the `MSG_MOVE_*` stream + every mover ack |
//! | [`lifecycle`] | being logged in: ping, logout, the cinematic ack |
//! | [`selection`] | the two sends that set our target: select, inspect |
//! | [`pvp`] | the honor system's one ask: another player's honor stats |
//! | [`names`] | the ask-once name lookups: player, creature, pet |
//! | [`chat`] | the chat frame's wire: every `MESSAGECHAT` flavour + `/played`, `/random`, `/wave` |
//! | [`channel`] | channel administration: join/leave/list/moderation |
@@ -73,6 +74,7 @@ mod pet;
mod player_flags;
mod pose;
mod progression;
mod pvp;
mod quest;
mod reputation;
mod selection;
@@ -0,0 +1,33 @@
//! The one honor-system send: asking for another player's honor stats (decision 1512). The
//! family's other message, `SMSG_PVP_CREDIT`, is inbound only.
//!
//! **Why its own module rather than [`super::selection`]** — that module is "the two sends that
//! set our selection", and this send does not: `HandleInspectOpcode` opens with
//! `SetSelectionGuid` (vmangos `MiscHandler.cpp:944`) while `HandleInspectHonorStatsOpcode` does
//! not (`MiscHandler.cpp:962-972`), so filing it there would make that module's own doc false. It
//! also follows `writer/mod.rs`'s stated, mechanical split rule: a send lives in the module named
//! after the [`crate::messages`] module its body builder lives in, and this body builder is
//! `messages::pvp::inspect_honor_stats`.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Ask for a player's honor stats (`MSG_INSPECT_HONOR_STATS`, a raw 8-byte guid — body and
/// server gates documented on [`messages::inspect_honor_stats`]). The reply rides the **same**
/// opcode and surfaces as [`crate::events::SessionEvent::InspectHonorStats`].
///
/// There is no failure reply: each of the server's three refusals (target offline, beyond
/// `INSPECT_DISTANCE`, a valid attack target) is a silent `return`. A caller must therefore
/// treat the ask as fire-and-forget and keep whatever it already had on screen, exactly as the
/// reference pane does — it re-asks on show and simply shows nothing until an answer lands.
pub fn inspect_honor_stats(&mut self, guid: u64) -> Result<()> {
self.send(
opcode::MSG_INSPECT_HONOR_STATS,
&messages::inspect_honor_stats(guid),
)
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The honor-system wire route end to end (decision 1512), mirroring `src/messages/pvp.rs`: raw
//! body → [`messages::parse_server`] → `ServerPacket` → [`decode`] → `SessionEvent`. The
//! byte-exact field goldens live inline in `pvp.rs`; what this file adds is the part those cannot
//! reach — that the opcode actually dispatches to the right reader, that the packet names itself
//! correctly in the log, and that the event fan-out carries the payload through unchanged. See
//! `tests/common` for the shared fixtures and methodology note.
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages;
use benilla_protocol::ServerPacket;
use common::hx;
/// The 50-byte `MSG_INSPECT_HONOR_STATS` reply, in `InspectHonorStatsResponse::AppendBodyTo`
/// order: guid `0x0000000100002AB3`, highestRank 11, sessionKills 17|2<<16, yesterdayHK 41,
/// unknownOld1 0, lastWeekHK 420, unknownOld2 0, thisWeekHK 123, unknownOld3 0, lifetimeHK 3907,
/// lifetimeDHK 12, yesterdayHonor 640, lastWeekHonor 8431, thisWeekHonor 1250, lastWeekRank 57,
/// rankBar 191.
fn inspect_honor_body() -> Vec<u8> {
hx(concat!(
"b32a000001000000", // u64 playerGuid
"0b", // u8 highestRank
"11000200", // u32 sessionKills (17 HK | 2 DK)
"2900", // u16 yesterdayHK
"0000", // u16 unknownOld1
"a401", // u16 lastWeekHK
"0000", // u16 unknownOld2
"7b00", // u16 thisWeekHK
"0000", // u16 unknownOld3
"430f0000", // u32 lifetimeHK
"0c000000", // u32 lifetimeDHK
"80020000", // u32 yesterdayHonor
"ef200000", // u32 lastWeekHonor
"e2040000", // u32 thisWeekHonor
"39000000", // u32 lastWeekRank (the standing)
"bf", // u8 rankBar
))
}
/// The reply dispatches off opcode 0x2D6 and reaches the UI as one event with every field intact.
/// The opcode is an `MSG_` — the same number carries our request — but `parse_server` only ever
/// sees inbound bodies, so there is nothing to disambiguate here.
#[test]
fn inspect_honor_stats_wire() {
let body = inspect_honor_body();
assert_eq!(body.len(), 50, "the 1.12.1 reply body is exactly 50 bytes");
let packet = messages::parse_server(messages::opcode::MSG_INSPECT_HONOR_STATS, &body).unwrap();
assert_eq!(packet.name(), "MSG_INSPECT_HONOR_STATS");
let ServerPacket::InspectHonorStats(stats) = packet else {
panic!("inspect honor stats");
};
assert_eq!(stats.player_guid, 0x0000_0001_0000_2AB3);
assert_eq!(stats.highest_rank, 11);
assert_eq!(stats.session_kills(), (17, 2));
assert_eq!(
(stats.yesterday_hk, stats.last_week_hk, stats.this_week_hk),
(41, 420, 123)
);
assert_eq!(
(stats.unknown_old1, stats.unknown_old2, stats.unknown_old3),
(0, 0, 0),
"vmangos writes all three deprecated slots as 0"
);
assert_eq!((stats.lifetime_hk, stats.lifetime_dhk), (3_907, 12));
assert_eq!(
(
stats.yesterday_honor,
stats.last_week_honor,
stats.this_week_honor
),
(640, 8_431, 1_250)
);
assert_eq!(stats.last_week_rank, 57, "the STANDING, not a rank");
assert_eq!(stats.rank_bar, 191);
match decode(ServerPacket::InspectHonorStats(stats)).as_slice() {
[SessionEvent::InspectHonorStats(ev)] => assert_eq!(*ev, stats),
other => panic!("one inspect-honor event, got {other:?}"),
}
// Our own request on the same opcode is the bare guid — a different shape entirely, which is
// exactly why direction and not content has to tell the two apart.
assert_eq!(
messages::inspect_honor_stats(0x0000_0001_0000_2AB3),
hx("b32a000001000000")
);
}
/// `SMSG_PVP_CREDIT` dispatches off 0x28C and carries honor / victim / rank through to the event.
/// The victim rank is the **internal** rank (`SendPVPCredit` sends `GetRank().rank`, floored at 5
/// for a player victim) — a direct `PVP_RANK_<rank>_<team>` key, not the visual badge number.
#[test]
fn pvp_credit_wire() {
// i32 honor = 143, u64 victimGuid = 0x0000000100002AB3, i32 victimRank = 11.
let body = hx("8f000000b32a0000010000000b000000");
assert_eq!(body.len(), 16);
let packet = messages::parse_server(messages::opcode::SMSG_PVP_CREDIT, &body).unwrap();
assert_eq!(packet.name(), "SMSG_PVP_CREDIT");
let ServerPacket::PvpCredit(credit) = packet else {
panic!("pvp credit");
};
assert_eq!(credit.honor, 143);
assert_eq!(credit.victim_guid, 0x0000_0001_0000_2AB3);
assert_eq!(credit.victim_rank, 11);
match decode(ServerPacket::PvpCredit(credit)).as_slice() {
[SessionEvent::PvpCredit(ev)] => assert_eq!(*ev, credit),
other => panic!("one pvp-credit event, got {other:?}"),
}
// A dishonorable kill rides the same packet with negative honor (`HonorMgr.cpp:807`).
let dk = hx("fbffffffb32a0000010000000b000000");
let ServerPacket::PvpCredit(credit) =
messages::parse_server(messages::opcode::SMSG_PVP_CREDIT, &dk).unwrap()
else {
panic!("pvp credit");
};
assert_eq!(credit.honor, -5);
}
/// A truncated body must surface as a parse error, not a panic — both opcodes, at the wire edge
/// rather than only at the reader.
#[test]
fn truncated_honor_bodies_error_at_the_dispatch() {
let full = inspect_honor_body();
assert!(
messages::parse_server(messages::opcode::MSG_INSPECT_HONOR_STATS, &full[..49]).is_err()
);
assert!(messages::parse_server(messages::opcode::SMSG_PVP_CREDIT, &[0u8; 15]).is_err());
}
+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