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