diff --git a/crates/benilla-app/assets/ui/CharacterFrame.xml b/crates/benilla-app/assets/ui/CharacterFrame.xml
index 4e132926..4a12958e 100644
--- a/crates/benilla-app/assets/ui/CharacterFrame.xml
+++ b/crates/benilla-app/assets/ui/CharacterFrame.xml
@@ -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 @@
-
@@ -1461,6 +1465,10 @@
BenillaCharacterFrameTab_OnClick(4)
+
+
+
+
+
+
+
+ BenillaHonorRow_OnEnter(self)
+ GameTooltip:Hide()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaHonorRow_OnLoad(self, HONORABLE_KILLS, NEWBIE_TOOLTIP_HONORABLE_KILLS)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaHonorRow_OnLoad(self, DISHONORABLE_KILLS, NEWBIE_TOOLTIP_DISHONORABLE_KILLS)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaHonorRow_OnLoad(self, HONOR_CONTRIBUTION_POINTS, NEWBIE_TOOLTIP_HONOR_CONTRIBUTION_POINTS)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaHonorRow_OnLoad(self, HONOR_STANDING, NEWBIE_TOOLTIP_HONOR_STANDING)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaHonorRow_OnLoad(self, RANK, NEWBIE_TOOLTIP_RANK)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaTooltip_AddNewbieTipFor(self, RANK, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK)
+ GameTooltip:Hide()
+
+
+
+
+
+
+
+
+
+
+ self:SetFrameLevel(self:GetFrameLevel() - 1)
+ BenillaTooltip_AddNewbieTipFor(self, RANK_POSITION, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK_POSITION)
+ GameTooltip:Hide()
+
+
+
+
+ self:SetFrameLevel(self:GetFrameLevel() - 1)
+
+
+
+
+
+ HonorFrame_OnLoad(self)
+ HonorFrame_OnEvent(event)
+
+
+
diff --git a/crates/benilla-app/assets/ui/InspectFrame.xml b/crates/benilla-app/assets/ui/InspectFrame.xml
index 465d684c..3c361078 100644
--- a/crates/benilla-app/assets/ui/InspectFrame.xml
+++ b/crates/benilla-app/assets/ui/InspectFrame.xml
@@ -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 ``, 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
]]>
+
+
-
+ 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. -->
+
@@ -611,6 +711,226 @@
BenillaInspectPaperDollFrame_OnShow()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaTooltip_AddNewbieTipFor(self, RANK, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK)
+ GameTooltip:Hide()
+
+
+
+
+
+
+
+
+
+
+ self:SetFrameLevel(self:GetFrameLevel() - 1)
+ BenillaTooltip_AddNewbieTipFor(self, RANK_POSITION, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK_POSITION)
+ GameTooltip:Hide()
+
+
+
+
+ self:SetFrameLevel(self:GetFrameLevel() - 1)
+
+
+
+
+
+ BenillaInspectHonorFrame_OnLoad(self)
+ BenillaInspectHonorFrame_OnEvent()
+ BenillaInspectHonorFrame_OnShow()
+
+
BenillaInspectFrame_OnLoad(self)
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index bd58ee3c..a7621aba 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -251,14 +251,31 @@ ReputationFrame.xml
# `parent=` attachment, so it's positioned by name via `` 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 ``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
diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs
index fa380d22..cf3a19ce 100644
--- a/crates/benilla-app/src/creature_anim.rs
+++ b/crates/benilla-app/src/creature_anim.rs
@@ -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::()
.add_message::()
+ // The pending-morph latch — the reference's per-unit `[+0xd54]` SpellRec slot.
+ .init_resource::()
.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,
diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs
index 710e0070..920395f5 100644
--- a/crates/benilla-app/src/creature_anim/spell_visual.rs
+++ b/crates/benilla-app/src/creature_anim/spell_visual.rs
@@ -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);
+
+/// 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>,
+ spells: Option>,
+ mut latch: ResMut,
+ mut seen: Local>>,
+) {
+ for (entity, store) in &changed {
+ let mut cur: Vec = 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,
+ visuals: Option>,
+ spells: Option>,
+ mut latch: ResMut,
+ mut play_seq: ResMut,
+ mut oneshots: MessageWriter,
+ mut wounds: MessageWriter,
+ mut sounds: MessageWriter,
+ mut fx: MessageWriter,
+ mut chain: MessageWriter,
+) {
+ 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,
+ );
+ }
+}
diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs
index 51d0448e..b58f4b1f 100644
--- a/crates/benilla-app/src/entities.rs
+++ b/crates/benilla-app/src/entities.rs
@@ -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::()
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
.add_message::()
+ // 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::()
.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.
diff --git a/crates/benilla-app/src/entities/live_display.rs b/crates/benilla-app/src/entities/live_display.rs
index 8e25e42b..2acaeea9 100644
--- a/crates/benilla-app/src/entities/live_display.rs
+++ b/crates/benilla-app/src/entities/live_display.rs
@@ -45,6 +45,16 @@ const SCALE_EASE_SECS: f32 = 2.0;
#[derive(Component)]
pub(super) struct AppliedDisplay(pub(super) Option);
+/// 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,
>,
+ mut swapped: MessageWriter,
) {
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 —
diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs
index d24769e2..dd7382c4 100644
--- a/crates/benilla-app/src/entities/spell_fx/mod.rs
+++ b/crates/benilla-app/src/entities/spell_fx/mod.rs
@@ -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::()
+ .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() {
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 56acdd12..6be9849e 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -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).
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index e2b88c3f..ede501c2 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -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.
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 783f5d76..9973a0cc 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -212,6 +212,10 @@ pub(super) fn apply_net_updates(
// the guard told you" is the client's to remember (`crate::poi_marker`).
ResMut,
Option>,
+ // 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,
),
),
),
@@ -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}");
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index 9dfe4ec9..e9744507 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -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(),
diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs
index 2dda0e76..aae996ab 100644
--- a/crates/benilla-app/src/ui_chat/event.rs
+++ b/crates/benilla-app/src/ui_chat/event.rs
@@ -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]
}
diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs
index f2415ec8..4f9cddc1 100644
--- a/crates/benilla-app/src/ui_chat/feed.rs
+++ b/crates/benilla-app/src/ui_chat/feed.rs
@@ -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,
diff --git a/crates/benilla-app/src/ui_chat/frames.rs b/crates/benilla-app/src/ui_chat/frames.rs
index b56c22de..992cf106 100644
--- a/crates/benilla-app/src/ui_chat/frames.rs
+++ b/crates/benilla-app/src/ui_chat/frames.rs
@@ -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(),
diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs
index 1f614618..bb4407e2 100644
--- a/crates/benilla-app/src/ui_chat/tests.rs
+++ b/crates/benilla-app/src/ui_chat/tests.rs
@@ -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]
diff --git a/crates/benilla-app/src/ui_honor.rs b/crates/benilla-app/src/ui_honor.rs
new file mode 100644
index 00000000..764a757c
--- /dev/null
+++ b/crates/benilla-app/src/ui_honor.rs
@@ -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);
+
+/// 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,
+}
+
+/// The per-VM change bases — the self snapshot and the inspect reply's guid.
+#[derive(Default)]
+struct HonorFeedMemo {
+ last: Option,
+ last_inspect: Option,
+}
+
+pub(crate) struct UiHonorPlugin;
+
+impl Plugin for UiHonorPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::()
+ .init_resource::()
+ .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 {
+ 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>,
+ self_store: Query<&ObjectStore, With>,
+ inspect_target: Res,
+ selection: Res,
+ group: Res,
+ mut inspect_honor: ResMut,
+ mut state: ResMut,
+ commands: Res,
+) {
+ 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::::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));
+ }
+}
diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs
index 871997da..64e6359d 100644
--- a/crates/benilla-app/src/ui_script/character_tests.rs
+++ b/crates/benilla-app/src/ui_script/character_tests.rs
@@ -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()"
diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs
new file mode 100644
index 00000000..25c3017c
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs
@@ -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/` 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::(&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::("return HonorFramePvPIcon:IsShown()")
+ .unwrap(),
+ "a ranked character shows a badge"
+ );
+ assert_eq!(
+ s.eval::("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::("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::("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::("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__`) 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::("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());
+}
diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs
index 4aaf5e33..3096ccda 100644
--- a/crates/benilla-app/src/ui_script/inspect_tests.rs
+++ b/crates/benilla-app/src/ui_script/inspect_tests.rs
@@ -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
diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs
index 910a97f2..bfa09247 100644
--- a/crates/benilla-app/src/ui_script/mod.rs
+++ b/crates/benilla-app/src/ui_script/mod.rs
@@ -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;
diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs
index 1840e50f..1df41a2f 100644
--- a/crates/benilla-app/src/ui_unit.rs
+++ b/crates/benilla-app/src/ui_unit.rs
@@ -561,6 +561,17 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option, 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` 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__`, 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__ has no team digit). \
+ `.gm off` restores both."
);
}
feed.warned_sideless = sideless;
diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs
index 92b997d8..d51d3395 100644
--- a/crates/benilla-protocol/src/events.rs
+++ b/crates/benilla-protocol/src/events.rs
@@ -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
diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs
index 4519316d..d6a33f5c 100644
--- a/crates/benilla-protocol/src/events/decode.rs
+++ b/crates/benilla-protocol/src/events/decode.rs
@@ -486,6 +486,8 @@ pub fn decode(packet: ServerPacket) -> Vec {
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 }]
diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs
index 56a750cf..d06ff20b 100644
--- a/crates/benilla-protocol/src/messages/mod.rs
+++ b/crates/benilla-protocol/src/messages/mod.rs
@@ -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,
diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs
index 3bc15962..53802684 100644
--- a/crates/benilla-protocol/src/messages/opcode.rs
+++ b/crates/benilla-protocol/src/messages/opcode.rs
@@ -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
diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs
index 3e556a35..ac192c98 100644
--- a/crates/benilla-protocol/src/messages/packet.rs
+++ b/crates/benilla-protocol/src/messages/packet.rs
@@ -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(),
diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs
index 8ffad0d8..40953063 100644
--- a/crates/benilla-protocol/src/messages/parse.rs
+++ b/crates/benilla-protocol/src/messages/parse.rs
@@ -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 {
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 => {
diff --git a/crates/benilla-protocol/src/messages/pvp.rs b/crates/benilla-protocol/src/messages/pvp.rs
new file mode 100644
index 00000000..07324082
--- /dev/null
+++ b/crates/benilla-protocol/src/messages/pvp.rs
@@ -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 {
+ 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 {
+ 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(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__` 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 {
+ 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_ 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());
+ }
+}
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
index 29c978fb..b394f0d6 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
@@ -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
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs
index eff463da..7cfe9650 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs
@@ -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 {
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__` 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 {
+ 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`
+ /// 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
self.unit_bytes_0().map(|b| (b & 0xff) as u8)
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs
index ea9b022f..a9443fc3 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs
@@ -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]
diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs
index 960c2fe2..913f2532 100644
--- a/crates/benilla-protocol/src/world/writer/mod.rs
+++ b/crates/benilla-protocol/src/world/writer/mod.rs
@@ -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;
diff --git a/crates/benilla-protocol/src/world/writer/pvp.rs b/crates/benilla-protocol/src/world/writer/pvp.rs
new file mode 100644
index 00000000..9cfeb34f
--- /dev/null
+++ b/crates/benilla-protocol/src/world/writer/pvp.rs
@@ -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),
+ )
+ }
+}
diff --git a/crates/benilla-protocol/tests/pvp.rs b/crates/benilla-protocol/tests/pvp.rs
new file mode 100644
index 00000000..00f69873
--- /dev/null
+++ b/crates/benilla-protocol/tests/pvp.rs
@@ -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 {
+ 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__` 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());
+}
diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs
index 95e3277a..bde30363 100644
--- a/crates/benilla-ui/src/script/mod.rs
+++ b/crates/benilla-ui/src/script/mod.rs
@@ -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};
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index 932a66b4..38f12678 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -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,
+ /// 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,
+ /// `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(),
diff --git a/crates/benilla-ui/src/script/pvp.rs b/crates/benilla-ui/src/script/pvp.rs
deleted file mode 100644
index 18a8162d..00000000
--- a/crates/benilla-ui/src/script/pvp.rs
+++ /dev/null
@@ -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::().expect("model app_data");
- model.pvp_toggles += 1;
- Ok(())
- })?,
- )
-}
diff --git a/crates/benilla-ui/src/script/pvp/mod.rs b/crates/benilla-ui/src/script/pvp/mod.rs
new file mode 100644
index 00000000..9b1ba805
--- /dev/null
+++ b/crates/benilla-ui/src/script/pvp/mod.rs
@@ -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__` — 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) {
+ 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) {
+ 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__` 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 {
+ 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 {
+ lua.app_data_ref::().expect("model app_data").honor
+}
+
+/// The last inspect-honor reply, or `None` when none is held.
+fn inspect_honor(lua: &Lua) -> Option {
+ lua.app_data_ref::()
+ .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 {
+ lua.globals()
+ .get::