diff --git a/crates/benilla-app/assets/ui/CharacterFrame.xml b/crates/benilla-app/assets/ui/CharacterFrame.xml index 4e132926..4a12958e 100644 --- a/crates/benilla-app/assets/ui/CharacterFrame.xml +++ b/crates/benilla-app/assets/ui/CharacterFrame.xml @@ -37,9 +37,10 @@ merchant-tab precedent ("tabs arrive with their pages") — `BENILLA_CHARACTERFRAME_SUBFRAMES` only lists the pages that exist. Skills arrived with decision 0437 phase 4 (`SkillFrame.xml`), **Pet with decision 1057** (`PetPaperDollFrame.xml`), which takes the - real client's own slot 2, and **Reputation** (`ReputationFrame.xml`), which takes its own - slot 3 and puts Skills back on the reference's 4 — so the first four tabs are the - reference's, in its order, at its numbers, and only Honor (its tab 5) is still out. Three things here are shared + real client's own slot 2, **Reputation** (`ReputationFrame.xml`), which takes its own + slot 3 and puts Skills back on the reference's 4, and **Honor** (`HonorFrame.xml`) on its + own 5 — so the row is COMPLETE: all five tabs are the reference's, in its order, at its + numbers, and nothing is omitted any more. Three things here are shared WITH the pet page rather than owned by this one, and each is flagged at its definition: the `(unit, prefix)` stat setters (including `_SetDefense`, which exists for the pet row alone), the `BenillaPaperDollModel_*` pane quartet (driven through each pane's own `frame.setFacing`), @@ -242,16 +243,18 @@ -- MagicResFrame1..5's ref `id=` attrs (ref-PaperDollFrame.xml:654-759) — arcane/fire/nature/ -- frost/shadow; Holy(1) never rides the paper doll (only 5 of the 6 schools show here). BENILLA_MAGICRES_IDS = { 6, 2, 3, 4, 5 } - -- The pages that exist so far (this file's header comment; the 0138 merchant-tab - -- precedent — tabs arrive with their pages), named exactly as the reference names them - -- ("PaperDollFrame"/"ReputationFrame"/"SkillFrame"). 1:1 with the tab row below (index N is - -- tab N's page) — decision 0437 phase 4 added the Skills page, decision 1057 the Pet one, - -- and the Reputation page (ReputationFrame.xml) takes the real client's own slot 3, which - -- puts Skills at the real client's own 4. The FIRST FOUR SLOTS ARE NOW THE REFERENCE'S: - -- only Honor (its tab 5) is still OMITTED, and it sits past the end where its absence - -- shifts nothing. + -- The window's pages, named exactly as the reference names them + -- ("PaperDollFrame"/"PetPaperDollFrame"/"ReputationFrame"/"SkillFrame"/"HonorFrame"). 1:1 + -- with the tab row below (index N is tab N's page) — decision 0437 phase 4 added the Skills + -- page, decision 1057 the Pet one, ReputationFrame.xml took the real client's own slot 3 + -- (which put Skills at the real client's own 4), and HonorFrame.xml takes the 5. + -- THE ROSTER IS NOW THE REFERENCE'S, WHOLE: five pages, five tabs, no omission left and + -- nothing past the end. The 0138 merchant-tab precedent ("tabs arrive with their pages") is + -- what kept this list a prefix of the real one until now, rather than a row of dead tabs. + -- `character_tests.rs` pins each page's `id=` against its slot here, which is what kept the + -- two in step as the middle of the row filled in. BENILLA_CHARACTERFRAME_SUBFRAMES = { - "PaperDollFrame", "PetPaperDollFrame", "ReputationFrame", "SkillFrame", + "PaperDollFrame", "PetPaperDollFrame", "ReputationFrame", "SkillFrame", "HonorFrame", } -- ── The window: toggle / tab dispatch (ref CharacterFrame.lua:1-48) ───────────────────── @@ -323,13 +326,14 @@ local frame = this -- GetID()/SetID() DO exist (landed with slice 2's BagFrame, which relies on them for -- its own per-window bag id) — the STALE claim this comment used to make is corrected - -- here (0208 phase 1b touched this function anyway). 4 tabs ship with the Reputation - -- page (Character + Pet + Reputation + Skills — the reference's own first four); the + -- here (0208 phase 1b touched this function anyway). All 5 tabs ship with the Honor + -- page (Character + Pet + Reputation + Skills + Honor — the reference's own five); the -- window opens on tab 1 by default, matching the real client. Tab 2 raises and hides -- itself with the pet — PetTab_Update, which the pet page runs at its own OnLoad and on -- every pet transition — so the COUNT here is the full row, not the currently-visible - -- one, exactly as the ref's SetNumTabs(5) is. - PanelTemplates_SetNumTabs(frame, 4) + -- one, which is why this number reaches the ref's own SetNumTabs(5) rather than + -- tracking what is on screen. + PanelTemplates_SetNumTabs(frame, 5) PanelTemplates_SetTab(frame, 1) -- The reference LABELS its numerals here (CharacterFrame_OnLoad l.55-57: "Health" on -- the player's health bar, "XP" on the XP bar, with the power bar's following the @@ -382,8 +386,9 @@ -- unlike `relativeTo`, which resolves by name regardless of tree position — see -- SkillFrame.xml's own header comment) — so nothing else would hide it when the WHOLE -- character window closes (ESC, the close X, or another left-slot window bumping it via - -- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once, - -- keeps this correct as more non-nested tabs (Pet/Reputation/Honor) land the same way. + -- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once, is + -- what carried that correctness through every non-nested tab that followed — Pet, + -- Reputation and now Honor, all four of the five pages that are separate files. function CharacterFrame_OnHide() if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igCharacterInfoClose") @@ -1426,13 +1431,12 @@ - @@ -1461,6 +1465,10 @@ BenillaCharacterFrameTab_OnClick(4) + + + + + + + + BenillaHonorRow_OnEnter(self) + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + BenillaHonorRow_OnLoad(self, HONORABLE_KILLS, NEWBIE_TOOLTIP_HONORABLE_KILLS) + + + + + + + + + + + + + + + + + + + BenillaHonorRow_OnLoad(self, DISHONORABLE_KILLS, NEWBIE_TOOLTIP_DISHONORABLE_KILLS) + + + + + + + + + + + + + + + + + + + BenillaHonorRow_OnLoad(self, HONOR_CONTRIBUTION_POINTS, NEWBIE_TOOLTIP_HONOR_CONTRIBUTION_POINTS) + + + + + + + + + + + + + + + + + + + BenillaHonorRow_OnLoad(self, HONOR_STANDING, NEWBIE_TOOLTIP_HONOR_STANDING) + + + + + + + + + + + + + + + + + + + BenillaHonorRow_OnLoad(self, RANK, NEWBIE_TOOLTIP_RANK) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTooltip_AddNewbieTipFor(self, RANK, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK) + GameTooltip:Hide() + + + + + + + + + + + self:SetFrameLevel(self:GetFrameLevel() - 1) + BenillaTooltip_AddNewbieTipFor(self, RANK_POSITION, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK_POSITION) + GameTooltip:Hide() + + + + + self:SetFrameLevel(self:GetFrameLevel() - 1) + + + + + + HonorFrame_OnLoad(self) + HonorFrame_OnEvent(event) + + + diff --git a/crates/benilla-app/assets/ui/InspectFrame.xml b/crates/benilla-app/assets/ui/InspectFrame.xml index 465d684c..3c361078 100644 --- a/crates/benilla-app/assets/ui/InspectFrame.xml +++ b/crates/benilla-app/assets/ui/InspectFrame.xml @@ -30,11 +30,17 @@ `CanInspect`/`NotifyInspect`/`ClearInspectPlayer`), the same posture `ToggleCharacter` takes. + THE HONOR TAB LANDED (ref tab 2, `Blizzard_InspectUI/InspectHonorFrame.{xml,lua}`, extracted + to session scratch alongside the rest of this window's reference). It was 0631's first named + deferral — it needed `MSG_INSPECT_HONOR_STATS` and an honor model that did not exist, and + the OWN character window had no Honor tab either — and the honor arc supplied all three at + once. The page is `BenillaInspectHonorFrame`, this window's own prefix convention; its five + row templates and both of its painters come from `HonorFrame.xml`, which is exactly the + sharing the reference does (its `HonorFrameTemplates.xml` is inherited straight into + `Blizzard_InspectUI`) and why that file must load first — see this window's `benilla.toc` + entry. + Named deferrals (decision 0631; each re-cited at its site): - - The HONOR tab (`InspectHonorFrame`, ref tab 2) — needs `MSG_INSPECT_HONOR_STATS` plus an - entire honor/PvP model that does not exist here, and the OWN character window's Honor - tab isn't built either. The tab row ships ONE entry, the 0138/0208 "tabs arrive with - their pages" precedent. - `InspectTitleText` / `InspectGuildText` — both `hidden="true"` in the ref itself, filled only from PVP-rank / guild-query data (0208's own two named gaps). Not transcribed. - Ctrl-click `DressUpItemLink` and shift-click chat-link insert (ref @@ -56,9 +62,13 @@ -- "string expected, got nil" when the window was driven without CharacterFrame loaded). PLAYER_LEVEL = PLAYER_LEVEL or "Level %d %s %s" -- :3075 - -- The subframe roster (ref INSPECTFRAME_SUBFRAMES, l.2). One page ships; the Honor tab is a - -- named deferral (header), and this list is what keeps the tab machinery honest as it lands. - BENILLA_INSPECTFRAME_SUBFRAMES = { "BenillaInspectPaperDollFrame" } + -- The subframe roster (ref INSPECTFRAME_SUBFRAMES, l.2), now the reference's own two. Index + -- N is tab N's page, and each page's `id=` is the same mapping written the other way round + -- — the invariant CharacterFrame.xml states for its own row, and the reason `ToggleInspect` + -- below can select the tab from the page rather than being told which one it is. + BENILLA_INSPECTFRAME_SUBFRAMES = { + "BenillaInspectPaperDollFrame", "BenillaInspectHonorFrame", + } -- The 19 inspect slots, in the ref's own `InspectPaperDollFrame_OnShow` order (l.60-78). -- Note there is NO ammo slot and NO bag buttons: a foreign player exposes neither. @@ -98,7 +108,7 @@ frame:RegisterEvent("UNIT_NAME_UPDATE") frame:RegisterEvent("UNIT_MODEL_CHANGED") frame.unit = nil - PanelTemplates_SetNumTabs(frame, 1) + PanelTemplates_SetNumTabs(frame, 2) PanelTemplates_SetTab(frame, 1) end @@ -136,25 +146,35 @@ -- ref InspectFrame_OnHide (l.53-59): drop the token, play the close sound, and tell the -- engine to stop inspecting (which stops the app's per-frame slot resolve). No subframe - -- hide-loop is needed (unlike CharacterFrame_OnHide's): the one page is a true - -- engine child of this frame, so it hides on the parent's cascade. + -- hide-loop is needed (unlike CharacterFrame_OnHide's): BOTH pages are true engine children + -- of this frame — they are declared inside its own ``, not as separate top-level + -- files the way the character window's four are — so they hide on the parent's cascade. + -- Dropping the token here is also what makes the honor page's nil check load-bearing: an + -- `MSG_INSPECT_HONOR_STATS` reply can outlive the window that asked for it. function BenillaInspectFrame_OnHide(frame) frame.unit = nil PlaySound("igCharacterInfoClose") ClearInspectPlayer() end - -- ref ToggleInspect (l.67-83) + InspectFrame_ShowSubFrame (l.85-93). With one page shipped - -- the interesting branch is the ref's own "clicking the tab of the page already showing - -- CLOSES the window" — kept rather than smoothed away, because that is what the real - -- tab does. ToggleInspect is BARE (not Benilla-prefixed), the addon-facing name, matching - -- this codebase's ToggleCharacter/ToggleQuestLog precedent. + -- ref ToggleInspect (l.67-83) + InspectFrame_ShowSubFrame (l.85-93). The ref's own "clicking + -- the tab of the page already showing CLOSES the window" is kept rather than smoothed away, + -- because that is what the real tab does. ToggleInspect is BARE (not Benilla-prefixed), the + -- addon-facing name, matching this codebase's ToggleCharacter/ToggleQuestLog precedent. + -- + -- THE TAB ROW IS SELECTED HERE, from the page's own id, on every path — the line + -- CharacterFrame.xml's `ToggleCharacter` carries and the defect it records (director, + -- 2026-08-06: a page switch that did not move the row left the old tab enabled, and + -- clicking it took the "already visible" arm and shut the window). This window could not + -- show that defect while it had one tab; it can now, so the line lands with the second one + -- rather than after the report. function ToggleInspect(tab) local subFrame = getglobal(tab) if not subFrame then return end local frame = getglobal("BenillaInspectFrame") + PanelTemplates_SetTab(frame, subFrame:GetID()) if frame:IsVisible() then if subFrame:IsVisible() then HideUIPanel(frame) @@ -345,6 +365,76 @@ BenillaInspectModel_SetFacing(frame.facing) end end + + -- ── The honor page (ref Blizzard_InspectUI/InspectHonorFrame.lua) ─────────────────────── + -- + -- WHY THIS PAGE WAITS WHERE THE PAPER DOLL DOES NOT. The gear page needs no round trip at + -- all — worn items ride PUBLIC descriptor fields the server already streams (this file's + -- header). A player's kill counters do not: they are private fields on their own object, + -- which we never see, so they come over a request and a reply of their own + -- (`MSG_INSPECT_HONOR_STATS`). Their RANK is the exception and rides the public bytes like + -- the gear does, which is why the rank block can paint from the unit while the counters + -- wait. + function BenillaInspectHonorFrame_OnLoad(frame) + frame:RegisterEvent("INSPECT_HONOR_UPDATE") + end + + function BenillaInspectHonorFrame_OnEvent() + if event == "INSPECT_HONOR_UPDATE" then + BenillaInspectHonorFrame_Update() + end + end + + -- ref InspectHonorFrame_OnShow: ask only when this player's numbers are not already held, + -- and repaint from them when they are. Both arms matter — the request arm is the first + -- visit, the repaint arm is every visit after it, and skipping the request when the answer + -- is already in hand is what keeps flipping between the two tabs from re-asking the server. + function BenillaInspectHonorFrame_OnShow() + if HasInspectHonorData() then + BenillaInspectHonorFrame_Update() + else + RequestInspectHonorData() + end + end + + function BenillaInspectHonorFrame_Update() + -- THE TOKEN IS THIS WINDOW'S OWN, not the reference's hardcoded "target". The ref reads + -- `UnitPVPRank("target")`/`UnitFactionGroup("target")` while its own paper doll reads + -- `this.unit`, so inspecting a PARTY member paints that party member's gear under + -- whoever you happen to be targeting's rank and faction colour. This window already + -- tracks the token it was opened on (`BenillaInspectFrame_Show` stores it; every other + -- read in this file goes through it), so the honor page reads it too and the two halves + -- of one window agree about whose player they are showing. + local unit = getglobal("BenillaInspectFrame").unit + -- The token is nil'd when the window closes, and the reply is asynchronous: closing + -- before it lands is an ordinary thing to do. The reference cannot notice, because + -- "target" is still a unit; ours would paint a rank block for nobody, so it doesn't. + if not unit then + return + end + + -- Twelve figures in one call, in the order the reply's own body carries them. + local sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisWeekHK, thisWeekHonor, + lastWeekHK, lastWeekHonor, lastWeekStanding, lifetimeHK, lifetimeDK, lifetimeRank = + GetInspectHonorData() + -- The same twelve rows the character window's page paints, through the same painter + -- (HonorFrame.xml) — one section per line, in the order they read down the page. + BenillaHonorPane_SetRows("BenillaInspectHonorFrame", { + CurrentHK = sessionHK, CurrentDK = sessionDK, + YesterdayHK = yesterdayHK, YesterdayContribution = yesterdayHonor, + ThisWeekHK = thisWeekHK, ThisWeekContribution = thisWeekHonor, + LastWeekHK = lastWeekHK, LastWeekContribution = lastWeekHonor, + LastWeekStanding = lastWeekStanding, + LifeTimeHK = lifetimeHK, LifeTimeDK = lifetimeDK, + LifeTimeRank = GetPVPRankInfo(lifetimeRank) or NONE, + }) + -- The rank block, off the unit for the rank and the reply for the bar. The two do come + -- from different places and that is not an inconsistency: the rank is public unit state + -- we can read at any time, the bar position is a private byte that only the reply + -- carries. + BenillaHonorPane_SetRank("BenillaInspectHonorFrame", UnitPVPRank(unit), + GetInspectPVPRankProgress(), unit) + end ]]> + + - + shape CharacterFrame.xml's own paper doll takes (its header has the history). + id=1 is the ref's own, and it is what `ToggleInspect` selects the tab from: a + page's id IS its slot in this window's tab row (the roster's own note above). It + was absent while one page shipped, because a row of one cannot disagree. --> + @@ -611,6 +711,226 @@ BenillaInspectPaperDollFrame_OnShow() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTooltip_AddNewbieTipFor(self, RANK, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK) + GameTooltip:Hide() + + + + + + + + + + + self:SetFrameLevel(self:GetFrameLevel() - 1) + BenillaTooltip_AddNewbieTipFor(self, RANK_POSITION, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b, NEWBIE_TOOLTIP_RANK_POSITION) + GameTooltip:Hide() + + + + + self:SetFrameLevel(self:GetFrameLevel() - 1) + + + + + + BenillaInspectHonorFrame_OnLoad(self) + BenillaInspectHonorFrame_OnEvent() + BenillaInspectHonorFrame_OnShow() + + BenillaInspectFrame_OnLoad(self) diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index bd58ee3c..a7621aba 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -251,14 +251,31 @@ ReputationFrame.xml # `parent=` attachment, so it's positioned by name via `` instead). Loaded after CharacterFrame.xml so it paints ON TOP of # it (later file = higher z) and so `CharacterFrame` already exists for that anchor to -# resolve against — and last of the three sibling pages, because it is the last of the -# three tabs (the real client's 4). +# resolve against — seated at the real client's own tab 4, between Reputation above and +# Honor below. SkillFrame.xml +# The Honor tab: the same separate-top-level-file shape as the three pages above it, and LAST of +# them because it is the last of the tabs (the real client's 5, ref HonorFrame.xml:5) — which +# CLOSES the row: the character window's five tabs are now the reference's five, in its order, at +# its numbers, with nothing left past the end. Its hard dependencies are all far above: +# CharacterFrame.xml (the anchor target it is pinned to by name, the tab row it raises tab 5 of, +# and the `CharacterNameText` its level line hangs under), GameTooltip.xml +# (`BenillaTooltip_AddNewbieTipFor`, which all five of its hovers go through) and Fonts.xml +# (GameFontGreenSmall/GameFontRedSmall carry the honorable/dishonorable colouring, and +# HIGHLIGHT_FONT_COLOR the tip titles). +# It must come BEFORE InspectFrame.xml, and that order is a REQUIREMENT rather than tidiness: the +# inspect window's own honor page inherits the five `HonorFrame*ButtonTemplate` row templates +# declared here, and `inherits=` resolves at LOAD. The reference has the identical edge — its +# `HonorFrameTemplates.xml` is ``d by `HonorFrame.xml` and inherited by the LoadOnDemand +# `Blizzard_InspectUI`, which loads later still. +HonorFrame.xml # The inspect window (decision 0631): another player's paper doll. Beside the character # window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus # `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml # itself (its slot template, handlers, and booth slot are all its own — the two windows -# share only the reference's art). +# share only the reference's art). ONE cross-window dependency does bind, and it is on the +# entry directly above: this window's honor page inherits HonorFrame.xml's five row templates +# and calls its two shared painters, the same sharing the reference does. InspectFrame.xml # The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the # windows whose slots feed it (the character window above, the bags before that) and before diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index fa380d22..cf3a19ce 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -694,8 +694,8 @@ use blood::{blood_spurts, load_blood_tables}; use env_damage::{hard_landing_dust, load_env_damage_table}; pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT}; use spell_visual::{ - arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, arm_mount_poof_fx, load_spell_visuals, - route_cast_visuals, + arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, arm_morph_latch, arm_mount_poof_fx, + load_spell_visuals, replay_morph_kit, route_cast_visuals, MorphLatch, }; // The aura-slot watcher is the shared trigger for BOTH halves of a state kit, so the CharProc half's // own tests (`crate::aura_visual`) drive it directly rather than re-deriving the slot diff. @@ -1008,6 +1008,8 @@ impl Plugin for CreatureAnimPlugin { // drained by `crate::aura_visual`, which owns the body's alpha/tint for an aura's life. .add_message::() .add_message::() + // The pending-morph latch — the reference's per-unit `[+0xd54]` SpellRec slot. + .init_resource::() .add_systems( Startup, ( @@ -1032,6 +1034,12 @@ impl Plugin for CreatureAnimPlugin { arm_mount_poof_fx, // The aura-slot watcher: state kits persist for the aura's life (the bread). arm_aura_state_fx, + // The pending-morph latch (wow-re `shapeshift-morph-cloud.md`): the aura + // add/remove edges arm it, and a display swap's rebuild replays the latched + // spell's impact kit — the shapeshift cloud, both directions. Arm before + // replay: the swap message crosses from last frame's teardown, and its + // latch was armed by the same wire burst. + (arm_morph_latch, replay_morph_kit).chain(), blood_spurts, // The landing predictor's dust leg (the vocal leg lives in `sound`). hard_landing_dust, diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 710e0070..920395f5 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -1334,3 +1334,138 @@ pub(super) fn arm_mount_poof_fx( // Streamed units despawn on range-out — drop their mount memory with them. displays.retain(|e, _| units.contains(*e)); } + +/// The one-slot **pending-morph latch** — the reference's `[unit+0xd54]`, a `SpellRec*` (wow-re +/// `shapeshift-morph-cloud.md`, §5 trio 2026-08-21): armed by `0x5ff0c0` on every aura **add +/// AND remove** whose spell passes [`is_morph_spell`]; consumed and cleared by the DISPLAYID +/// rebuild `0x60abe0`, which — after draining every attached effect and rebuilding the model — +/// **replays the latched spell's IMPACT kit** (stage 1, `0x6ec1e0` → `[SpellVisual+0xc]` → +/// `0x60edf0` at `0x60ad67`, cleared at `0x60ad6c`). That replay IS the druid-morph cloud the +/// player sees, both directions: shift-in's SPELL_GO impact instance dies in the drain and the +/// replay restores it in sync with the swap; shift-out has no GO at all (the cancel is +/// client-side, `shapeshift-plaincast-toggle.md`) — the aura-REMOVE handler's last call +/// (`0x6123ad`) re-arms the latch, and the aura fields precede DISPLAYID in the applier's +/// ascending field order, so the demorph revert replays the same kit. A swap with no latch — a +/// GM morph, a revive — plays nothing. One entry per unit (a one-slot field); entries die with +/// the unit ([`arm_morph_latch`]'s sweep). +#[derive(Resource, Default)] +pub(crate) struct MorphLatch(EntityHashMap); + +/// The latch-arm predicate `0x5ff100`: any effect slot with `Effect == 6` (APPLY_AURA) whose +/// `EffectApplyAuraName` is 36 (MOD_SHAPESHIFT) or 56 (TRANSFORM) — slot-paired, and NO other +/// gate (no class/positive/unit-type test anywhere in `0x5ff0c0`). +fn is_morph_spell(spells: &crate::ui_action::Spells, spell_id: u32) -> bool { + const SPELL_EFFECT_APPLY_AURA: u32 = 6; + const SPELL_AURA_MOD_SHAPESHIFT: u32 = 36; + const SPELL_AURA_TRANSFORM: u32 = 56; + spells.catalog.get(spell_id).is_some_and(|d| { + d.effects.iter().zip(&d.effect_apply_aura).any(|(&e, &a)| { + e == SPELL_EFFECT_APPLY_AURA + && (a == SPELL_AURA_MOD_SHAPESHIFT || a == SPELL_AURA_TRANSFORM) + }) + }) +} + +/// Arm [`MorphLatch`] from the aura-slot diff — the reference's TWO arm sites folded into the +/// one place benilla sees both edges: the aura-add watcher and the aura-remove handler's tail +/// (`0x6123ad`) both call `0x5ff0c0(spellId)`, and a later arm overwrites an earlier one (one +/// slot). First sight of a unit seeds the diff baseline silently — the reference's watchers +/// fire on VALUES deltas, never on create, so a druid streaming in mid-form must not latch. +/// Keyed to its own full-slot diff rather than [`arm_aura_state_fx`]'s `armed` map, which +/// tracks only state-kit spells — the form spells carry none. +pub(super) fn arm_morph_latch( + units: Query<(Entity, &ObjectStore)>, + changed: Query<(Entity, &ObjectStore), Changed>, + spells: Option>, + mut latch: ResMut, + mut seen: Local>>, +) { + for (entity, store) in &changed { + let mut cur: Vec = store.0.unit_auras().map(|a| a.spell_id).collect(); + cur.sort_unstable(); + cur.dedup(); + match seen.entry(entity) { + bevy::platform::collections::hash_map::Entry::Vacant(e) => { + e.insert(cur); // first sight arms silently + } + bevy::platform::collections::hash_map::Entry::Occupied(mut e) => { + let prev = e.get_mut(); + if *prev == cur { + continue; // some other field moved + } + if let Some(spells) = spells.as_deref() { + // Remove edges first, add edges second: a form→form swap latches the form + // being ENTERED (both resolve to the same cloud family regardless). + let removed = prev.iter().filter(|s| cur.binary_search(s).is_err()); + let added = cur.iter().filter(|s| prev.binary_search(s).is_err()); + for &spell_id in removed.chain(added) { + if is_morph_spell(spells, spell_id) { + debug!("anim: morph latch armed ({entity}, spell {spell_id})"); + latch.0.insert(entity, spell_id); + } + } + } + *prev = cur; + } + } + } + // Streamed units despawn on range-out — the latch and the baseline die with them. + latch.0.retain(|e, _| units.contains(*e)); + seen.retain(|e, _| units.contains(*e)); +} + +/// The rebuild's **impact-kit replay** — the tail of the reference's `0x60abe0` (wow-re +/// `shapeshift-morph-cloud.md`): a display swap that finds the latch armed plays the latched +/// spell's impact kit as an ordinary discrete kit play ([`KitPlay::DISCRETE`] — full +/// `PlaySpellVisualKit`: effects, sound, anim, CharProcs) and clears the latch; a swap with no +/// latch plays nothing. Runs off [`crate::entities::DisplaySwapped`], which the swap writes as +/// it tears the visual down — the message crosses to the next frame, so the replay's instance +/// spawns onto (or pends for) the rebuilt body, never the corpse of the old one. Named +/// approximation: the reference's same-record+same-tag dedup `0x6208e0` is not modelled, so a +/// cold-cache shift-in — whose SPELL_GO impact instance was still PENDING at the drain and thus +/// survived — briefly runs that instance and the replay's twin together (one denser cloud, once +/// per session per model; the warm-cache GO instance dies in the drain like the reference's). +#[allow(clippy::too_many_arguments)] // one Bevy system's full input set +pub(super) fn replay_morph_kit( + mut swaps: MessageReader, + visuals: Option>, + spells: Option>, + mut latch: ResMut, + mut play_seq: ResMut, + mut oneshots: MessageWriter, + mut wounds: MessageWriter, + mut sounds: MessageWriter, + mut fx: MessageWriter, + mut chain: MessageWriter, +) { + for swap in swaps.read() { + // Consume-and-clear even when the kit resolves to nothing — the reference clears + // unconditionally at `0x60ad6c`. + let Some(spell_id) = latch.0.remove(&swap.entity) else { + continue; // no latch — a GM morph / revive swap plays nothing + }; + let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else { + continue; // no client data — no spell visuals (the DBC-resource degrade shape) + }; + let Some(kit) = resolve_kit(spells, &visuals.0, spell_id, |s| s.impact, || None) else { + continue; // a morph spell with no impact kit — a silent swap + }; + debug!("anim: morph replay ({}, spell {spell_id})", swap.entity); + let mut out = KitOut { + oneshots: &mut oneshots, + wounds: &mut wounds, + sounds: &mut sounds, + fx: &mut fx, + chain: &mut chain, + }; + play_kit( + swap.entity, + spell_id, + &kit, + KitPlay::DISCRETE, + play_seq.next(), + &visuals.0, + &mut out, + ); + } +} diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 51d0448e..b58f4b1f 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -70,6 +70,7 @@ use mount::reseat_mounts; /// model in place (druid forms, GM morphs — ledger B69/F04) and one moving `SCALE_X` eases the /// render scale (the reference's 2 s cosine smoothstep); both restamp the collision height. mod live_display; +pub(crate) use live_display::DisplaySwapped; use live_display::{refresh_live_display, tick_scale_ease}; /// Terrain conform (decisions 0482/0486): every flagged model (`GlobalModelFlags & 3 ∈ {1,3}` — @@ -598,6 +599,9 @@ impl Plugin for EntitiesPlugin { .add_message::() // The cast router's dest one-shot orders (`dest_fx`, decision 0797). .add_message::() + // A live display-id swap's rebuild edge — consumed by the morph-latch replay + // (`crate::creature_anim`), the reference's `0x60abe0` impact-kit replay tail. + .add_message::() .add_systems(Startup, setup_entities.after(AssetSet::Open)) // The map-scope teardown (`world_map::MapChange`): drop every display/material dedup // so a map's assets actually die with it — the #bugs teleport leak. diff --git a/crates/benilla-app/src/entities/live_display.rs b/crates/benilla-app/src/entities/live_display.rs index 8e25e42b..2acaeea9 100644 --- a/crates/benilla-app/src/entities/live_display.rs +++ b/crates/benilla-app/src/entities/live_display.rs @@ -45,6 +45,16 @@ const SCALE_EASE_SECS: f32 = 2.0; #[derive(Component)] pub(super) struct AppliedDisplay(pub(super) Option); +/// A live display-id swap happened on this entity — the visual was torn down and will rebuild. +/// The reference's rebuild `0x60abe0` ends by REPLAYING the pending-morph latch's impact kit +/// (wow-re `shapeshift-morph-cloud.md`); this edge is what carries "the rebuild ran" to that +/// replay (`crate::creature_anim`'s morph-latch watcher), which owns the latch and the kit +/// resolve. Written only when the rebuild actually fires (the diff branch — our `0x60ae10`). +#[derive(Message, Clone, Copy)] +pub(crate) struct DisplaySwapped { + pub(crate) entity: Entity, +} + /// A live render-scale ease toward [`NetEntity::scale`]: the reference's 2 s cosine smoothstep /// (`0x614bbf`), ticked by [`tick_scale_ease`] as absolute writes (a mid-ease visual rebuild's /// snap is simply overwritten next frame, so the ease survives it). @@ -94,6 +104,7 @@ pub(super) fn refresh_live_display( ), With, >, + mut swapped: MessageWriter, ) { for (entity, guid, mut net, store, applied, height, tf) in &mut entities { let mut restamp = false; @@ -108,6 +119,7 @@ pub(super) fn refresh_live_display( ); net.display_id = Some(live); restamp = true; + swapped.write(DisplaySwapped { entity }); // The full visual teardown set + our own diff key: children (parts, anchors, // held roots, mount child) despawn, the per-instance visual components strip, and // `attach_entity_visuals` rebuilds next frame(s) with the new display — diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index d24769e2..dd7382c4 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -828,18 +828,39 @@ pub(super) fn attach_spell_fx( // teardown of the unit's visual (a live display swap, `live_display`; before 0835 a // gear change; before B199's fix a mount transition) leaves the instance holding a // dangling entity, and this gate's `root.is_some()` then means it is never rebuilt. - // The aura watcher cannot notice either: it is edge-driven off the slot set, and the - // aura never left it. 0835 named this open; here it closes. A **persistent** instance - // re-spawns onto the rebuilt body (its aura is still live, so its visual must be); - // a self-terminating flash or one already playing its `Decay` out simply goes. + // The reference's rebuild `0x60abe0` DRAINS the whole `+0xb4` list unconditionally + // and then RE-CREATES what must persist — the aura-state re-arm `0x5ff130`, the + // channel re-arm `0x612a30` (wow-re `shapeshift-morph-cloud.md`, which REFUTED the + // earlier survives-the-rebuild reading). Our edge-driven aura watcher cannot notice + // (the aura never left the slots), so re-arming the PERSISTENT instance here IS that + // re-create leg, in the one place that sees the drain. A one-shot goes, exactly as + // the reference's drained Mount Poof does — the shapeshift cloud is NOT survival: + // it is the pending-morph latch's impact-kit REPLAY after the rebuild + // (`creature_anim::spell_visual::replay_morph_kit`), and a decaying instance was + // ending anyway. if let Some(root) = inst.root { if commands.get_entity(root).is_ok() { return true; } if !inst.persistent || inst.decaying { + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fx", + &format!( + "kit collateral drain unit={unit} path={} decaying={}", + inst.path, inst.decaying + ), + ); + } return false; } inst.root = None; + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fx", + &format!("kit collateral re-create unit={unit} path={}", inst.path), + ); + } } // Still pending: a self-terminating instance gets a generous deadline so a unit whose // model never materialises (the cube fallback) can't accumulate unspawnable flashes — @@ -1073,12 +1094,14 @@ mod tests { .collect() } - /// **0835's named-open item, closed.** `FxAttached` lives on the unit and outlives its model — - /// exactly as the reference's per-CGUnit `+0xb4` effect list does — so a teardown of the unit's - /// visual (a live display swap) leaves each instance holding a dangling root, and the spawn - /// gate's `root.is_some()` then means it is never rebuilt. The aura watcher cannot notice: it - /// is edge-driven off the slot set, and the aura never left it. A **persistent** instance must - /// come back; a self-terminating flash must not. + /// **The drain-then-recreate law** (0835's named-open item closed; the reference byte-read + /// in wow-re `shapeshift-morph-cloud.md`): the rebuild drains EVERY effect node and then + /// re-creates what must persist. A teardown of the unit's visual leaves each instance + /// holding a dangling root; the persistent instance re-arms (our re-create leg — the + /// edge-driven aura watcher cannot notice, the aura never left the slots), the one-shot + /// flash goes exactly as the reference's drained Mount Poof does. The shapeshift cloud is + /// deliberately NOT this path's business — it is the morph latch's post-rebuild replay + /// (`creature_anim::spell_visual::replay_morph_kit`). #[test] fn a_persistent_instance_whose_root_died_as_collateral_is_re_armed() { let (mut app, unit, roots) = standing(&[true, false]); @@ -1090,10 +1113,26 @@ mod tests { assert_eq!( instances_of(&app, unit), vec![(true, None)], - "the aura's instance is re-armed for the spawn pass; the flash is dropped", + "the aura's instance re-arms for the spawn pass; the one-shot drains", ); } + /// A decaying persistent instance was already ending — the drain takes it too (a re-spawn + /// would replay its death rattle from nothing on the new body). + #[test] + fn a_decaying_instance_whose_root_died_as_collateral_is_dropped() { + let (mut app, unit, roots) = standing(&[true]); + app.world_mut() + .entity_mut(unit) + .get_mut::() + .unwrap() + .instances[0] + .decaying = true; + app.world_mut().entity_mut(roots[0]).despawn(); + app.update(); + assert_eq!(instances_of(&app, unit), vec![]); + } + /// A live root is left strictly alone — the gate is "is it still there?", not "re-spawn me". #[test] fn a_live_root_is_never_disturbed() { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 56acdd12..6be9849e 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -111,6 +111,7 @@ mod ui_gamma; mod ui_gossip; mod ui_guild; mod ui_hide; +mod ui_honor; mod ui_inspect; mod ui_item_text; mod ui_items; @@ -580,6 +581,10 @@ pub fn run(build: BuildId) -> AppExit { // The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item // entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors. .add_plugins(ui_inspect::InspectUiPlugin) + // The honor feed (decision 1512): the PRIVATE honor descriptor block as the snapshot both + // Honor tabs read, plus the inspect-honor round trip. After the inspect feed because it + // resolves that feed's target to address its request at. + .add_plugins(ui_honor::UiHonorPlugin) // The dressing-room feed (decision 1060): the window's try-on intents → the player's own look // with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect // feed, whose shape it shares (intents in, a booth look out). diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index e2b88c3f..ede501c2 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -1273,6 +1273,12 @@ pub(crate) enum ClientCommand { /// paints from the already-streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields. Sent anyway because /// server-side it also sets our selection (`MiscHandler.cpp:945`), as the real client's does. Inspect { target: u64 }, + /// Ask for a player's honor stats (`MSG_INSPECT_HONOR_STATS`, `u64 target`) — the inspect + /// window's Honor tab (decision 1512). Unlike [`Self::Inspect`] this one is a **real** round + /// trip: the reply rides the same opcode and is the only source of another player's honor + /// numbers, since every field of the honor block is PRIVATE. A refusal is silence — the server + /// applies the same three gates as `CMSG_INSPECT` and simply does not answer. + InspectHonorStats { target: u64 }, // ── The player-trade arc (decision 0592; writer bodies in benilla-protocol // `world/writer/trade.rs`). ───────────────────────────────────────────────────────────── /// Offer to trade with a player (`CMSG_INITIATE_TRADE`, `u64 target`) — the UnitPopup TRADE row. diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 783f5d76..9973a0cc 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -212,6 +212,10 @@ pub(super) fn apply_net_updates( // the guard told you" is the client's to remember (`crate::poi_marker`). ResMut, Option>, + // The inspect-honor reply (decision 1512) — `MSG_INSPECT_HONOR_STATS` is the only + // source of another player's honor numbers, so the reply parks here and + // `crate::ui_honor` pushes it into the pane and fires `INSPECT_HONOR_UPDATE`. + ResMut, ), ), ), @@ -368,6 +372,7 @@ pub(super) fn apply_net_updates( mut binder, mut poi_marker, current_map, + mut inspect_honor, ), ), ) = caches; @@ -646,6 +651,29 @@ pub(super) fn apply_net_updates( session::reputation_visible(list_id, &mut reputations) } SessionEvent::BindPoint { area } => home_bind.0 = Some(area), + // The honor arc's two inbound messages (decision 1512). + // + // The inspect reply REPLACES whatever is held, including for a different player: the + // reference's latch is a single slot, and a pane still showing the last target's + // kills is the failure keeping the old one produces. + SessionEvent::InspectHonorStats(stats) => inspect_honor.0 = Some(stats), + // An honor award: the combat-log line (name-resolved, so it queues) and the floating + // number, which are two different surfaces of one packet and are both the reference's. + // A DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text + // takes it signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a + // "+" only when the number is positive and therefore already expects the other case. + SessionEvent::PvpCredit(credit) => { + chat_log.push_pvp_credit( + credit.honor, + credit.victim_guid, + u8::try_from(credit.victim_rank).unwrap_or(0), + ); + audio.15 .1.write(crate::ui_unit::CombatTextEvent { + message_type: "HONOR_GAINED", + data: Some(credit.honor.to_string()), + extra: None, + }); + } SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc), SessionEvent::PlayerBound { binder: npc, area } => { debug!("net: bound to area {area} by {npc:#x}"); diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 9dfe4ec9..e9744507 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -873,6 +873,9 @@ fn writer_loop( ClientCommand::QueryTime => w.query_time(), // The inspect request (decision 0631) — no reply is awaited; see the writer. ClientCommand::Inspect { target } => w.inspect(target), + // The inspect-honor query (decision 1512) — this one IS answered; the reply + // rides the same opcode back. + ClientCommand::InspectHonorStats { target } => w.inspect_honor_stats(target), // The player-trade arc (decision 0592) — the CMSG verbs onto the P0 writers. ClientCommand::InitiateTrade { target } => w.initiate_trade(target), ClientCommand::BeginTrade => w.begin_trade(), diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs index 2dda0e76..aae996ab 100644 --- a/crates/benilla-app/src/ui_chat/event.rs +++ b/crates/benilla-app/src/ui_chat/event.rs @@ -55,6 +55,12 @@ pub(crate) enum ChatEventKind { /// The client-composed XP line (`SMSG_LOG_XPGAIN`, decision 0304) — the one `COMBAT_*` /// family member modeled (0288 §3 keeps the rest for the combat-log content arc). CombatXpGain, + /// The client-composed honor line (`SMSG_PVP_CREDIT`, decision 1512) — the XP line's twin, + /// and the second `COMBAT_*` family member modeled. Composed here rather than fired from the + /// wire because the packet carries a guid and a rank *number*: the sentence needs the + /// victim's NAME and their rank TITLE, so it is built after the name resolve exactly as + /// [`Self::CombatXpGain`] is. + CombatHonorGain, RaidLeader, RaidWarning, RaidBossEmote, @@ -105,6 +111,7 @@ impl ChatEventKind { K::Loot, K::Money, K::CombatXpGain, + K::CombatHonorGain, K::RaidLeader, K::RaidWarning, K::RaidBossEmote, @@ -279,6 +286,7 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str { K::Loot => "CHAT_MSG_LOOT", K::Money => "CHAT_MSG_MONEY", K::CombatXpGain => "CHAT_MSG_COMBAT_XP_GAIN", + K::CombatHonorGain => "CHAT_MSG_COMBAT_HONOR_GAIN", K::RaidLeader => "CHAT_MSG_RAID_LEADER", K::RaidWarning => "CHAT_MSG_RAID_WARNING", K::RaidBossEmote => "CHAT_MSG_RAID_BOSS_EMOTE", @@ -370,6 +378,8 @@ pub(crate) enum ChatGroup { Money, /// `COMBAT_XP_GAIN` — one kind, its own group (ref ChatFrame.lua l.235-237). CombatXpGain, + /// `COMBAT_HONOR_GAIN` — one kind, its own group (ref ChatFrame.lua l.238-240). + CombatHonorGain, } /// `ChatTypeGroup` transcribed (ref ChatFrame.lua l.116-174): which kinds a group registration @@ -417,6 +427,7 @@ pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] { ChatGroup::Loot => &[K::Loot, K::Money], ChatGroup::Money => &[K::Money], ChatGroup::CombatXpGain => &[K::CombatXpGain], + ChatGroup::CombatHonorGain => &[K::CombatHonorGain], } } @@ -449,6 +460,7 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] { K::Loot => [0, 170, 0], K::Money => [255, 255, 0], K::CombatXpGain => [111, 111, 255], + K::CombatHonorGain => [224, 202, 10], K::RaidLeader | K::RaidWarning | K::RaidBossEmote | K::BattlegroundLeader => { [255, 219, 183] } diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index f2415ec8..4f9cddc1 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -91,6 +91,22 @@ enum Pending { bonus: u32, tries: u16, }, + /// An honor award awaiting the victim's name (`SMSG_PVP_CREDIT`; decision 1512) — the XP + /// node's twin, parked for the identical reason: the sentence's first `%s` is a NAME and the + /// packet carries a guid. + /// + /// `rank` is the victim's **internal** rank as the packet sent it (0 = none), turned into a + /// title by the drain — which is the right place for it, because the title's GlobalString key + /// needs the victim's own **side and sex**, and those come out of the very `NameCache` record + /// the name resolve below is already waiting on (`SMSG_NAME_QUERY_RESPONSE` carries + /// race/class/gender beside the name). Resolving it at push time would have needed the + /// victim's descriptor, which is gone the moment their body despawns. + HonorGain { + victim: u64, + honor: i32, + rank: u8, + tries: u16, + }, /// A text emote (`SMSG_TEXT_EMOTE`) awaiting the PERFORMER's name — decision 1274. /// /// Parked here for the reference's own reason, not merely for convenience: `0x49dbe0` composes @@ -296,6 +312,25 @@ impl ChatLog { } } + /// Queue an honor award's chat line (`SMSG_PVP_CREDIT` → CHAT_MSG_COMBAT_HONOR_GAIN; + /// decision 1512). A credit with **no victim guid** is the bonus/objective form and needs no + /// resolve at all, so it composes here and now — the same fork [`Self::push_xp_gain`] takes. + pub(crate) fn push_pvp_credit(&mut self, honor: i32, victim: u64, rank: u8) { + if victim != 0 { + self.pending.push(Pending::HonorGain { + victim, + honor, + rank, + tries: 0, + }); + } else { + self.pending.push(Pending::Event(ChatEvent::text_only( + ChatEventKind::CombatHonorGain, + honor_gain_line(None, None, honor), + ))); + } + } + /// Queue a text emote's chat line (`SMSG_TEXT_EMOTE` → CHAT_MSG_TEXT_EMOTE; decision 1274) for /// the performer-name resolve, then the `EmotesText`/`EmotesTextData` composition. pub(crate) fn push_text_emote(&mut self, performer: u64, text_id: u32, target: String) { @@ -419,6 +454,33 @@ pub(super) fn xp_gain_line(victim: Option<&str>, total: u32, bonus: u32) -> Stri } } +/// The honor line an `SMSG_PVP_CREDIT` becomes — the three GlobalStrings forms (COMBATLOG_HONORAWARD +/// :786, COMBATLOG_HONORGAIN :787, COMBATLOG_DISHONORGAIN :785), decision 1512. +/// +/// **The fork is byte-VERIFIED** (wow-re `system/ui/scratch/honor-panel-law.md`, formatter +/// `0x625270`), and it is three-way, not two: no victim guid → AWARD; victim and `honor > 0` → +/// GAIN; victim and **`honor <= 0`** → DISHONOR. The boundary is `<=`, not `<` — a zero-honor kill +/// takes the dishonorable arm, which the pre-verdict reading had on the honorable side. +/// +/// An absent `rank_title` formats an EMPTY rank slot rather than dropping the clause. That is the +/// reference's own shape — it is precisely the emptiness vmangos floors a rankless victim's rank at +/// 5 to avoid showing (1512), and compensating for it a second time on our side would hide what the +/// server is actually sending. +pub(super) fn honor_gain_line( + victim: Option<&str>, + rank_title: Option<&str>, + honor: i32, +) -> String { + match victim { + None => format!("You have been awarded {honor} honor points."), + Some(name) if honor <= 0 => format!("{name} dies, dishonorable kill."), + Some(name) => format!( + "{name} dies, honorable kill Rank: {} (Estimated Honor Points: {honor})", + rank_title.unwrap_or_default() + ), + } +} + /// The discovery toast — GlobalStrings ERR_ZONE_EXPLORED ("Discovered: %s"), fired on every /// exploration packet to the UIErrorsFrame (byte-verified: error-table route 1 → /// `AddErrorMessage 0x4945b0` → UI_INFO_MESSAGE; decisions 0828/0829). @@ -866,6 +928,51 @@ pub(super) fn feed_chat( ), ); } + Pending::HonorGain { + victim, + honor, + rank, + tries, + } => { + let name = names.resolve(victim, &commands).map(str::to_string); + if name.is_none() && tries < NAME_MAX_TRIES { + still.push(Pending::HonorGain { + victim, + honor, + rank, + tries: tries + 1, + }); + continue; + } + // **The side is the VICTIM's and the gender is OURS**, and that asymmetry is the + // reference's own (`0x625270`): the team digit is computed inline over the victim's + // faction template, while the gendered GlobalString resolve runs against the local + // player. It reads like a bug in the real client and it is what the bytes do; both + // halves are wow-re-VERIFIED. The victim's side rides the same name-query record we + // just resolved the name from; ours rides our own. + // + // A victim with no record is a creature — a racial leader is the only one the + // server ranks, at 19, and "Leader" is the same word on both sides — or an + // unanswered guid, so the side digit cannot change the answer there. + let team = names + .player_traits(victim) + .and_then(|(race, _, _)| crate::ui_unit::race_faction_group(race)) + .map_or(0, |group| u8::from(group == "Alliance")); + let female = self_guid + .0 + .and_then(|g| names.player_traits(g)) + .is_some_and(|(_, _, gender)| gender == 1); + let title = script.pvp_rank_title(rank, team, female); + let name = name.unwrap_or_else(|| "Unknown".into()); + route( + &mut script, + &mut windows, + &ChatEvent::text_only( + ChatEventKind::CombatHonorGain, + honor_gain_line(Some(&name), title.as_deref(), honor), + ), + ); + } Pending::TextEmote { performer, text_id, diff --git a/crates/benilla-app/src/ui_chat/frames.rs b/crates/benilla-app/src/ui_chat/frames.rs index b56c22de..992cf106 100644 --- a/crates/benilla-app/src/ui_chat/frames.rs +++ b/crates/benilla-app/src/ui_chat/frames.rs @@ -58,10 +58,16 @@ impl Default for ChatWindows { ChatGroup::Skill, ChatGroup::Loot, ], - // WINDOW 2 "Combat Log": of its chat-cache list, MONEY and COMBAT_XP_GAIN are - // the groups the current kind set carries (the rest of the combat/spell block is - // the combat-log arc; XP joined with the ding arc, 0304). - vec![ChatGroup::Money, ChatGroup::CombatXpGain], + // WINDOW 2 "Combat Log": of its chat-cache list, MONEY, COMBAT_XP_GAIN and + // COMBAT_HONOR_GAIN are the groups the current kind set carries (the rest of the + // combat/spell block is the combat-log arc; XP joined with the ding arc, 0304, + // and honour with the honor arc, 1512 — the reference registers the two on the + // same frame one line apart, ChatFrame.lua l.2428-2429). + vec![ + ChatGroup::Money, + ChatGroup::CombatXpGain, + ChatGroup::CombatHonorGain, + ], ], tell_alert_left: 0.0, default_language: String::new(), @@ -176,6 +182,7 @@ pub(crate) fn compose( | K::Loot | K::Money | K::CombatXpGain + | K::CombatHonorGain | K::BgSystemNeutral | K::BgSystemAlliance | K::BgSystemHorde => event.text.clone(), diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 1f614618..bb4407e2 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -269,6 +269,42 @@ fn level_up_lines_follow_the_reference_order_and_forms() { ); } +/// The three honor forms (COMBATLOG_HONORAWARD / COMBATLOG_HONORGAIN / COMBATLOG_DISHONORGAIN, +/// GlobalStrings :786/:787/:785) and the fork between them, decision 1512. +/// +/// The empty-rank case is asserted deliberately: it is what the server's floor-at-5 exists to +/// prevent, so a change that silently starts hiding the clause instead would pass unnoticed here +/// without it. +#[test] +fn honor_gain_lines_pick_the_reference_form() { + assert_eq!( + super::feed::honor_gain_line(None, None, 42), + "You have been awarded 42 honor points." + ); + assert_eq!( + super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 137), + "Grimtusk dies, honorable kill Rank: Sergeant (Estimated Honor Points: 137)" + ); + // A dishonorable kill: vmangos sends the same packet with a negative honor + // (`HonorMgr.cpp:807`), and the client's fork is on the sign. + assert_eq!( + super::feed::honor_gain_line(Some("Innkeeper Renee"), None, -37), + "Innkeeper Renee dies, dishonorable kill." + ); + // The BOUNDARY, byte-verified at `0x625270`: the test is `honor <= 0`, so a zero-honor kill + // takes the dishonorable arm. The pre-verdict reading had `< 0` and put this one on the + // honorable side, where it would have printed "Rank: (Estimated Honor Points: 0)". + assert_eq!( + super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 0), + "Grimtusk dies, dishonorable kill." + ); + // No rank title: the clause stays, empty — the reference's own shape. + assert_eq!( + super::feed::honor_gain_line(Some("Grimtusk"), None, 5), + "Grimtusk dies, honorable kill Rank: (Estimated Honor Points: 5)" + ); +} + #[test] fn xp_gain_lines_pick_the_reference_form() { // COMBATLOG_XPGAIN_FIRSTPERSON / its EXHAUSTION1 rested form / _UNNAMED (GlobalStrings @@ -895,7 +931,7 @@ fn every_kind_is_in_all() { seen.sort_unstable(); seen.dedup(); assert_eq!(seen.len(), before, "a kind is listed twice in ALL"); - assert_eq!(before, 36, "36 kinds — update this when the kind set grows"); + assert_eq!(before, 37, "37 kinds — update this when the kind set grows"); } #[test] diff --git a/crates/benilla-app/src/ui_honor.rs b/crates/benilla-app/src/ui_honor.rs new file mode 100644 index 00000000..764a757c --- /dev/null +++ b/crates/benilla-app/src/ui_honor.rs @@ -0,0 +1,341 @@ +//! The app-side **honor feed** (decision 1512) — the local player's PRIVATE honor descriptor +//! fields turned into the snapshot both Honor tabs read, the inspect-honor request/reply round +//! trip, and the `SMSG_PVP_CREDIT` award turned into its chat line and floating number. +//! +//! The display law lives in `benilla-ui`'s `script::pvp`; this is the **data** law it consumes, +//! and there are three pieces of it. +//! +//! ## 1 · The self snapshot is one descriptor read, and every field of it is PRIVATE +//! +//! The whole honor block (`PLAYER_FIELD_SESSION_KILLS` … `PLAYER_FIELD_BYTES2`) streams for +//! nobody but the local player, so there is no per-unit form of this feed and there cannot be one. +//! What a *foreign* player exposes is exactly two things — the PUBLIC current-rank byte, which +//! rides [`crate::ui_unit`]'s snapshot, and whatever `MSG_INSPECT_HONOR_STATS` chooses to answer. +//! That asymmetry is the shape of the entire arc. +//! +//! ## 2 · The two events are a field diff, and the reference watches **exactly three fields** +//! +//! The pane repaints on `PLAYER_PVP_KILLS_CHANGED` and `PLAYER_PVP_RANK_CHANGED`, which the real +//! engine fires from field watches. wow-re carved the watch table (`0x467e70`, callback +//! `0x5de4b0` carrying the event id) and there are **three registrations and no more**: +//! +//! | watched | fires | +//! |---|---| +//! | `PLAYER_FIELD_SESSION_KILLS` | `PLAYER_PVP_KILLS_CHANGED` (523) | +//! | `PLAYER_BYTES_3` | `PLAYER_PVP_RANK_CHANGED` (524) | +//! | `PLAYER_FIELD_BYTES2` byte 0 | `PLAYER_PVP_RANK_CHANGED` (524) | +//! +//! **Nothing watches the yesterday / this-week / last-week / lifetime / contribution fields, and +//! nothing watches `+0x102b` (the highest-lifetime rank).** That is not an omission in the client; +//! it is *why* `HonorFrame.lua` refreshes those rows on `PLAYER_ENTERING_WORLD` alone. The two +//! facts are one fact, and a feed that fired `KILLS_CHANGED` on a lifetime total — which this one +//! did until the verdict landed — would be firing an event the real client has no source for. +//! +//! One divergence, stated: the reference watches the **whole `PLAYER_BYTES_3` dword**, so a +//! drunkenness change fires `PLAYER_PVP_RANK_CHANGED` there too. We watch byte 3 alone. The +//! repaint that spurious fire produces is byte-identical to the one before it, so the difference +//! is unobservable; carrying the other three bytes in an honor snapshot to reproduce it would cost +//! a field nothing reads. +//! +//! ## 3 · The inspect round trip is a real one, unlike `CMSG_INSPECT` +//! +//! 0631's inspect request is fire-and-forget: the gear was already streamed and `SMSG_INSPECT` +//! echoes the guid. This one is the opposite — `MSG_INSPECT_HONOR_STATS` is the *only* source of +//! another player's honor numbers, the reply rides the **same opcode** as the request, and the +//! reference's pane gates on `HasInspectHonorData()` precisely because the data may not be here +//! yet. So: the pane asks, we resolve the inspect target's guid and send, the reply lands in +//! [`InspectHonor`], and `INSPECT_HONOR_UPDATE` tells the pane to repaint. +//! +//! **A refusal is silent by construction.** The server answers nothing when the target is gone, +//! out of the 10-yard `INSPECT_DISTANCE`, or attackable (`MiscHandler.cpp:962-977`) — there is no +//! error shape on this opcode. The pane's own `OnShow` re-asks whenever it holds nothing, which is +//! the reference's retry and the only one there is. + +use bevy::prelude::*; + +use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript}; + +use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; +use crate::ui_script::{UiInput, VmMemo}; + +/// The inspect-honor reply we currently hold, or `None` before one lands. +/// +/// Keyed by nothing but its own `player_guid`: the reply carries whose it is, and the pane is +/// keyed by a unit token, so the app is where the two are matched. One slot rather than a map — +/// the reference's `HasInspectHonorData` is a single latch, and a window that can only inspect one +/// player at a time needs no more. +#[derive(Resource, Default)] +pub(crate) struct InspectHonor(pub(crate) Option); + +/// What the last push told this VM, so the feed pushes and fires only on real change. +/// +/// Behind a [`VmMemo`] (1290/1291) for the reason every other feed's is: a `/reload` replaces the +/// VM without despawning the world, and a memory of what the *old* VM was told would leave the new +/// one with an empty pane and no event ever coming. +#[derive(Resource, Default)] +struct HonorFeedState { + vm: VmMemo, +} + +/// The per-VM change bases — the self snapshot and the inspect reply's guid. +#[derive(Default)] +struct HonorFeedMemo { + last: Option, + last_inspect: Option, +} + +pub(crate) struct UiHonorPlugin; + +impl Plugin for UiHonorPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Update, feed_honor.in_set(UiInput)); + } +} + +/// Read the honor block off our own descriptor, or `None` while none of it has streamed. +/// +/// **`None` and all-zeroes are different states and the difference is visible**: a fresh character +/// who has never fought has every counter at 0 *and* the fields present, while a player who has +/// only just entered the world may have none of them yet. The reference paints the second as +/// blank, not as zero, so the feed pushes nothing until at least one field arrives rather than +/// inventing a zeroed snapshot. +fn honor_snapshot(store: &ObjectStore) -> Option { + let f = &store.0; + let session = f.player_session_kills(); + let yesterday = f.player_yesterday_kills(); + let last_week = f.player_last_week_kills(); + let this_week = f.player_this_week_kills(); + // The rank byte is PUBLIC and arrives with the unit block, so it alone is a poor presence + // test; the honor block proper is what we wait for. + session?; + Some(HonorState { + session_hk: session.map_or(0, |(hk, _)| hk), + session_dk: session.map_or(0, |(_, dk)| dk), + yesterday_hk: yesterday.map_or(0, |(hk, _)| hk), + yesterday_dk: yesterday.map_or(0, |(_, dk)| dk), + yesterday_honor: f.player_yesterday_contribution().unwrap_or(0), + this_week_hk: this_week.map_or(0, |(hk, _)| hk), + this_week_honor: f.player_this_week_contribution().unwrap_or(0), + last_week_hk: last_week.map_or(0, |(hk, _)| hk), + last_week_dk: last_week.map_or(0, |(_, dk)| dk), + last_week_honor: f.player_last_week_contribution().unwrap_or(0), + last_week_standing: f.player_last_week_rank().unwrap_or(0), + lifetime_hk: f.player_lifetime_honorable_kills().unwrap_or(0), + lifetime_dk: f.player_lifetime_dishonorable_kills().unwrap_or(0), + // The HIGHEST lifetime rank (PRIVATE, `PLAYER_FIELD_BYTES` byte 3) … + highest_rank: f.player_honor_rank().unwrap_or(0), + // … and the CURRENT one (PUBLIC, `PLAYER_BYTES_3` byte 3). Two bytes, two fields, and + // they are equal for anyone who has never ranked down — which is exactly why reading one + // for the other would have shipped green (decision 1512). + rank: f.player_pvp_rank().unwrap_or(0), + rank_bar: f.player_honor_rank_bar().unwrap_or(0), + }) +} + +/// Which of the two reference events a change between two snapshots deserves — the module doc's +/// three-row watch table, written out. A first push is both: the pane has never painted, and +/// either event repaints it. +/// +/// **Most of `HonorState` fires nothing at all.** The weekly figures, the two lifetime totals and +/// the highest-lifetime rank are unwatched in the real client, so they ride the pane's world-entry +/// repaint and move here in silence. Listing the watched fields positively — rather than +/// "everything that is not a rank byte", which is what this did before the RE verdict — is what +/// keeps a field added later from inventing an event for itself. +fn events_for(before: Option<&HonorState>, after: &HonorState) -> (bool, bool) { + let Some(b) = before else { + return (true, true); + }; + // `PLAYER_FIELD_SESSION_KILLS`, both halves — the one field behind `PLAYER_PVP_KILLS_CHANGED`. + let kills = |h: &HonorState| (h.session_hk, h.session_dk); + // `PLAYER_BYTES_3` byte 3 and `PLAYER_FIELD_BYTES2` byte 0 — the two behind + // `PLAYER_PVP_RANK_CHANGED`. NOT `highest_rank`: that is `+0x102b`, and nothing watches it. + let ranks = |h: &HonorState| (h.rank, h.rank_bar); + (kills(b) != kills(after), ranks(b) != ranks(after)) +} + +/// Push the self snapshot and the inspect reply, fire what moved, and drain the pane's request. +#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set +fn feed_honor( + script: Option>, + self_store: Query<&ObjectStore, With>, + inspect_target: Res, + selection: Res, + group: Res, + mut inspect_honor: ResMut, + mut state: ResMut, + commands: Res, +) { + let Some(mut script) = script else { + return; + }; + let memo = state.vm.get(&script); + + // --- the self snapshot ------------------------------------------------------------------- + if let Ok(store) = self_store.single() { + if let Some(fresh) = honor_snapshot(store) { + if memo.last.as_ref() != Some(&fresh) { + let (kills, ranks) = events_for(memo.last.as_ref(), &fresh); + script.set_honor(Some(fresh)); + memo.last = Some(fresh); + if kills { + script.fire_event("PLAYER_PVP_KILLS_CHANGED", vec![]); + } + if ranks { + script.fire_event("PLAYER_PVP_RANK_CHANGED", vec![]); + } + } + } + } + + // --- the inspect reply ------------------------------------------------------------------- + // + // **A reply is only valid while it is about the player currently being inspected.** The + // reference's `HasInspectHonorData()` is the latch its pane's `OnShow` gates on: hold stale + // data and inspecting a SECOND player repaints the FIRST one's kills, with no request ever + // sent and nothing on screen to say so. + // + // **The real client's latch is invalidated by exactly one thing, and it is not this one** + // (wow-re `honor-panel-law.md`): the slot is a single un-keyed store, and `0x4c6f70` — reached + // from `NotifyInspect` — is its only GUID writer *and* its only invalidator. A `NotifyInspect` + // naming a different player zeroes both flags; the same player is a no-op; there is no timeout; + // and `ClearInspectPlayer` (the stock `InspectFrame_OnHide`) clears it outright. + // + // **We invalidate on the inspected TOKEN's guid moving instead, deliberately.** Our inspect + // window re-resolves its token every frame so the paper doll follows a re-target (0631), and + // the honor page reads that same token rather than the reference's hardcoded `"target"` — so + // matching the reference's latch exactly would let one window show two different players' + // data at once, on its two tabs. The reference cannot notice because its two pages disagree + // about whose player they show in the first place. Ours agree, and this comparison is what + // keeps them agreeing. + // + // (`ClearInspectPlayer` is covered too, one level up: it drops the token, `inspected` reads + // `None`, and the mismatch below clears the slot.) + let inspected = inspect_target + .token + .as_deref() + .and_then(|t| crate::ui_unit::player_token_guid(t, &selection, &group)); + if inspect_honor + .0 + .as_ref() + .is_some_and(|reply| Some(reply.player_guid) != inspected) + { + // Dropped from the RESOURCE, not merely filtered on the way out: a reply nobody may read + // is not data we are keeping, and leaving it here would make `HasInspectHonorData` and + // this store disagree about what is held. + inspect_honor.0 = None; + } + let held = inspect_honor.0.as_ref().map(|r| r.player_guid); + if memo.last_inspect != held { + script.set_inspect_honor(inspect_honor.0.as_ref().map(|r| { + let (session_hk, session_dk) = r.session_kills(); + InspectHonorData { + guid: r.player_guid, + session_hk, + session_dk, + yesterday_hk: r.yesterday_hk, + yesterday_honor: r.yesterday_honor, + this_week_hk: r.this_week_hk, + this_week_honor: r.this_week_honor, + last_week_hk: r.last_week_hk, + last_week_honor: r.last_week_honor, + last_week_standing: r.last_week_rank, + lifetime_hk: r.lifetime_hk, + lifetime_dk: r.lifetime_dhk, + highest_rank: r.highest_rank, + rank_bar: r.rank_bar, + } + })); + memo.last_inspect = held; + // Fired on a *clear* as well as on an arrival: the pane's handler re-reads + // `GetInspectHonorData`, and a window left showing the previous player's kills is the + // failure a silent clear produces. + script.fire_event("INSPECT_HONOR_UPDATE", Vec::::new()); + } + + // --- the pane's request ------------------------------------------------------------------ + // + // `RequestInspectHonorData()` takes no argument — the engine holds the inspected player, so + // the app resolves it, exactly as `NotifyInspect`'s token is resolved. The token is re-read + // now rather than remembered from the notify, so a re-target between opening the window and + // opening its Honor tab asks about whoever is actually being inspected. + let requests = script.take_inspect_honor_requests(); + if requests > 0 { + match inspected { + Some(guid) => { + debug!("honor: inspect-honor request -> {guid:#x}"); + let _ = commands + .0 + .send(ClientCommand::InspectHonorStats { target: guid }); + } + // No inspect target (or it is not streamed): nothing to ask about. The pane will ask + // again from its next `OnShow`, which is the reference's only retry. + None => debug!("honor: inspect-honor request with no resolvable target — not sent"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> HonorState { + HonorState { + session_hk: 3, + lifetime_hk: 900, + rank: 8, + highest_rank: 9, + rank_bar: 128, + ..Default::default() + } + } + + /// The event split is the whole point of [`events_for`]: a kill must not claim a rank change, + /// and a rank must not claim a kill. An addon that registers only one of the two is what makes + /// the distinction observable. + #[test] + fn each_event_fires_only_for_its_own_half() { + let base = state(); + + let mut killed = base; + killed.session_hk += 1; + assert_eq!(events_for(Some(&base), &killed), (true, false)); + + let mut ranked = base; + ranked.rank = 9; + assert_eq!(events_for(Some(&base), &ranked), (false, true)); + + // The bar moving is a rank event too — it is the rank's own progress, and the reference's + // pane redraws the bar from the same handler as the title. + let mut barred = base; + barred.rank_bar = 200; + assert_eq!(events_for(Some(&base), &barred), (false, true)); + + // The unwatched half: a weekly figure, a lifetime total and the highest-lifetime rank all + // move in silence, because the real client registers no watch on any of them (wow-re's + // carve of `0x467e70` — the module doc's table). This is the assertion the pre-verdict + // implementation failed: it fired `KILLS_CHANGED` for all three. + for mutate in [ + (|h: &mut HonorState| h.last_week_standing = 42) as fn(&mut HonorState), + |h: &mut HonorState| h.lifetime_hk = 5_000, + |h: &mut HonorState| h.yesterday_honor = 900, + |h: &mut HonorState| h.highest_rank = 18, + ] { + let mut quiet = base; + mutate(&mut quiet); + assert_eq!( + events_for(Some(&base), &quiet), + (false, false), + "an unwatched field must fire nothing" + ); + } + } + + /// A first push has to fire both, or a pane whose numbers arrived before it existed never + /// paints — the `ui_unit` "first resolve counts as a transition" rule. + #[test] + fn the_first_push_fires_both() { + assert_eq!(events_for(None, &state()), (true, true)); + } +} diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index 871997da..64e6359d 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -945,15 +945,17 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() { load_xml(&s, "PetPaperDollFrame.xml"); load_xml(&s, "ReputationFrame.xml"); load_xml(&s, "SkillFrame.xml"); + load_xml(&s, "HonorFrame.xml"); s.set_unit("player", Some(player_unit())); // THE INVARIANT the fix rests on: a page's `id=` is its slot in this window's own tab row, and // `BENILLA_CHARACTERFRAME_SUBFRAMES` is the same 1:1 mapping written the other way round. It // did its job: this loop is what made the Reputation page's arrival LOUD, since Skills had to - // move from 3 to 4 in the same breath. The four slots are the reference's own now - // (Character/Pet/Reputation/Skills, ref `CharacterFrame.xml:79-168`); only Honor, its tab 5, is - // still out, and it sits past the end where its absence shifts nothing. - for i in 1..=4 { + // move from 3 to 4 in the same breath. All five slots are the reference's own now + // (Character/Pet/Reputation/Skills/Honor, ref `CharacterFrame.xml:79-168`), so the loop covers + // the whole row rather than a prefix of it — there is nothing left past the end for a page to + // arrive into unnoticed. + for i in 1..=5 { let id: i64 = s .eval(&format!( "return getglobal(BENILLA_CHARACTERFRAME_SUBFRAMES[{i}]):GetID()" diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs new file mode 100644 index 00000000..25c3017c --- /dev/null +++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs @@ -0,0 +1,466 @@ +//! The shipped **Honor tab** (`assets/ui/HonorFrame.xml`) driven end-to-end, engine-only (no +//! Bevy) — the per-window test module the skills/reputation files establish (decision 1512). +//! +//! What it pins is the PAINT law, the half `benilla-ui`'s own `script::pvp::tests` structurally +//! cannot reach: that module drives the thirteen globals and asserts their tuples, and stops at the +//! seam. Everything below is about what those tuples become on screen — which figure lands in which +//! of the thirteen row slots, what the rank block does with a nil title, which badge file the +//! arithmetic names, and the weekly/session repaint split. +//! +//! Two of these are the arc's own traps, and they are why this file exists rather than trusting the +//! engine tests: +//! +//! - **The badge is the VISUAL rank and the title is the INTERNAL one.** They differ by four, both +//! are numbers, and both come out of one call. A pane that indexed the badge by the internal rank +//! would put a Sergeant's art on a Knight-Captain and no tuple assertion would notice. +//! - **The weekly sections repaint on world entry only.** A pane that repainted everything on every +//! kill would look identical on screen for a whole session and differ only in what it costs — +//! which is to say, it would never be caught by looking. + +use benilla_ui::script::{HonorState, UiScript, UnitState}; + +/// Load one shipped `assets/ui/` into `s`, panicking on any loader error (the +/// character/reputation tests' loader, duplicated so this file is self-contained). +fn load_xml(s: &UiScript, file: &str) { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("assets/ui") + .join(file), + ) + .unwrap(); + let doc = benilla_ui::framexml::parse(&text).unwrap(); + let report = benilla_ui::loader::load(s, &doc, &|_| None); + assert!( + report.errors.is_empty(), + "{file}: loader errors: {:?}", + report.errors + ); +} + +/// The fixture snapshot — a rank-12 Alliance character with a figure in every bucket, all thirteen +/// distinct so a swapped pair of rows cannot pass. Deliberately the same numbers as the protocol +/// crate's 50-byte `inspect_honor_stats_golden`, so a failure here can be read straight against the +/// bytes one layer down. +fn state() -> HonorState { + HonorState { + session_hk: 17, + session_dk: 2, + yesterday_hk: 41, + yesterday_dk: 0, + yesterday_honor: 640, + this_week_hk: 123, + this_week_honor: 1_250, + last_week_hk: 420, + last_week_dk: 0, + last_week_honor: 8_431, + last_week_standing: 57, + lifetime_hk: 3_907, + lifetime_dk: 12, + // Two DIFFERENT ranks on purpose: the current one the badge and title draw, and the + // higher lifetime best the "Highest Rank" row shows. A pane that read one for the other + // passes every same-value fixture. + rank: 12, + highest_rank: 14, + // Three quarters through the rank (the binding's constant × 191). + rank_bar: 191, + } +} + +/// The eight rank titles this fixture can ask for, defined as the install's `GlobalStrings.lua` +/// defines them. Set by hand rather than read off the chain so the pane's law is pinned on a +/// machine with no client data; [`the_real_global_strings_name_the_rank`] is the same assertion +/// against the player's actual file. +const RANK_GLOBALS: &str = r#" + NONE = "None" + RANK = "Rank" + PLAYER_LEVEL = "Level %d %s %s" + PVP_RANK_12_1 = "Knight-Captain" + PVP_RANK_12_0 = "Legionnaire" + PVP_RANK_14_1 = "Lieutenant Commander" + PVP_RANK_14_0 = "Champion" +"#; + +fn load_page(s: &UiScript) { + for file in [ + "Fonts.xml", + "UiPanels.xml", + "UIParent.xml", + "GameTooltip.xml", + "TextStatusBar.xml", + "ScrollTemplates.xml", + "UIPanelTemplates.xml", + "OptionsFrameTemplates.xml", + "CharacterFrame.xml", + "HonorFrame.xml", + ] { + load_xml(s, file); + } +} + +/// An Alliance, male, level-60 player — the two facts the rank title's GlobalString key is built +/// from (side → the team digit, sex → the `_FEMALE` twin), plus the level line's input. +fn alliance_player() -> UnitState { + UnitState { + exists: true, + level: 60, + sex: 2, + faction_group: Some("Alliance".into()), + race: Some("Human".into()), + class: Some("Warrior".into()), + pvp_rank: 12, + ..UnitState::default() + } +} + +/// The Honor page open on its tab with [`state`] pushed, as world entry leaves it (`weekly` true, +/// so every section has been painted at least once). +fn shown_honor_page() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.run(RANK_GLOBALS).unwrap(); + load_page(&s); + s.set_unit("player", Some(alliance_player())); + s.set_honor(Some(state())); + s.run(r#"ToggleCharacter("HonorFrame")"#).unwrap(); + s.run("HonorFrame_Update(1)").unwrap(); + s.resolve(); + s +} + +fn text(s: &mut UiScript, frame: &str) -> String { + s.eval::(&format!("return {frame}:GetText()")) + .unwrap_or_else(|e| panic!("{frame}:GetText(): {e}")) +} + +/// Every figure the snapshot carries lands in its own row. Thirteen distinct numbers against +/// thirteen slots: any transposition — the two lifetime totals, this week against last week, the +/// standing against a kill count — fails here and only here. +#[test] +fn every_figure_lands_in_its_own_row() { + let mut s = shown_honor_page(); + for (frame, want) in [ + ("HonorFrameCurrentHKValue", "17"), + ("HonorFrameCurrentDKValue", "2"), + ("HonorFrameYesterdayHKValue", "41"), + ("HonorFrameYesterdayContributionValue", "640"), + ("HonorFrameThisWeekHKValue", "123"), + ("HonorFrameThisWeekContributionValue", "1250"), + ("HonorFrameLastWeekHKValue", "420"), + ("HonorFrameLastWeekContributionValue", "8431"), + ("HonorFrameLastWeekStandingValue", "57"), + ("HonorFrameLifeTimeHKValue", "3907"), + ("HonorFrameLifeTimeDKValue", "12"), + // The HIGHEST rank's title, not the current one — rank 14, not rank 12. + ("HonorFrameLifeTimeRankValue", "Lieutenant Commander"), + ] { + assert_eq!(text(&mut s, frame), want, "{frame}"); + } + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The rank block: the title is keyed by the INTERNAL rank and the badge by the VISUAL one, which +/// differ by four. This is the arc's central conflation trap (decision 1512) and the assertion that +/// makes it impossible to ship. +#[test] +fn the_title_is_the_internal_rank_and_the_badge_is_the_visual_one() { + let mut s = shown_honor_page(); + assert_eq!(text(&mut s, "HonorFrameCurrentPVPTitle"), "Knight-Captain"); + // Internal 12 → visual 8. The line reads "(Rank 8)", never "(Rank 12)". + assert_eq!(text(&mut s, "HonorFrameCurrentPVPRank"), "(Rank 8)"); + assert!( + s.eval::("return HonorFramePvPIcon:IsShown()") + .unwrap(), + "a ranked character shows a badge" + ); + assert_eq!( + s.eval::("return HonorFramePvPIcon:GetTexture()") + .unwrap(), + "Interface\\PvPRankBadges\\PvPRank08", + "the badge file is the VISUAL rank, zero-padded" + ); +} + +/// Rank 0 — every character who has never taken an honorable kill. The title's GlobalString does +/// not exist (there is no `PVP_RANK_0_*`), the binding answers nil, and the pane says NONE with no +/// badge. The nil is the mechanism, so this is the case that proves the pane never invents a title. +#[test] +fn an_unranked_character_reads_none_and_shows_no_badge() { + let mut s = shown_honor_page(); + s.set_honor(Some(HonorState { + rank: 0, + highest_rank: 0, + ..state() + })); + s.set_unit( + "player", + Some(UnitState { + pvp_rank: 0, + ..alliance_player() + }), + ); + s.run("HonorFrame_Update(1)").unwrap(); + s.resolve(); + assert_eq!(text(&mut s, "HonorFrameCurrentPVPTitle"), "None"); + assert_eq!(text(&mut s, "HonorFrameCurrentPVPRank"), "(Rank 0)"); + assert_eq!(text(&mut s, "HonorFrameLifeTimeRankValue"), "None"); + assert!( + !s.eval::("return HonorFramePvPIcon:IsShown()") + .unwrap(), + "rank 0 has no badge to show" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The bar takes the 0..1 fraction straight, and wears the player's own faction colour. Alliance +/// navy here; the `else` arm is Horde red for everyone else, including a unit whose side has not +/// resolved — a bar with no colour at all would read as a broken pane. +#[test] +fn the_bar_takes_the_fraction_and_the_faction_colour() { + let s = shown_honor_page(); + // Against the binding's own arithmetic, not against `191/255`. The client MULTIPLIES by the + // f32 nearest 1/255 (`0x3B808081`, wow-re `honor-panel-law.md` `0x51aace`) rather than + // dividing, and the two answers differ in the eighth decimal — a tolerance loose enough to + // accept both would stop pinning the correction the moment it was made. + let want = 191.0 * f64::from(f32::from_bits(0x3B80_8081)); + let binding = s.eval::("return GetPVPRankProgress()").unwrap(); + assert!( + (binding - want).abs() < 1e-12, + "the binding answers 191 × the reference's own constant = {want}, got {binding}" + ); + // The BAR is checked at `f32`, and that is not a slackened assertion — a `StatusBar`'s value is + // a C `float` in the real client and an `f32` in ours, so the round trip through the widget is + // *supposed* to lose the tail. Asserting the exact `f64` here would be asserting that our + // status bars are wider than the reference's. + let value = s + .eval::("return HonorFrameProgressBar:GetValue()") + .unwrap(); + assert_eq!( + value as f32, want as f32, + "the bar holds the binding's answer at widget precision" + ); + let (r, g, b) = s + .eval::<(f64, f64, f64)>("return HonorFrameProgressBar:GetStatusBarColor()") + .unwrap(); + assert!( + (r - 0.05).abs() < 1e-6 && (g - 0.15).abs() < 1e-6 && (b - 0.36).abs() < 1e-6, + "Alliance navy, got ({r}, {g}, {b})" + ); +} + +/// The repaint split the reference wrote and we kept: a kill moves the session and rank blocks, and +/// leaves the three weekly sections alone until world entry says otherwise. +/// +/// Driven through the real `OnEvent` rather than by calling `HonorFrame_Update` directly, because +/// the flag's *derivation from the event name* is the half that can be got wrong. +#[test] +fn a_kill_repaints_the_session_but_not_the_week() { + let mut s = shown_honor_page(); + // Everything moves in the snapshot — including a weekly figure, which is the bait. + s.set_honor(Some(HonorState { + session_hk: 18, + this_week_hk: 124, + ..state() + })); + s.run(r#"HonorFrame_OnEvent("PLAYER_PVP_KILLS_CHANGED")"#) + .unwrap(); + s.resolve(); + assert_eq!( + text(&mut s, "HonorFrameCurrentHKValue"), + "18", + "the session block follows a kill" + ); + assert_eq!( + text(&mut s, "HonorFrameThisWeekHKValue"), + "123", + "the weekly block does NOT — it moves at the server's weekly maintenance" + ); + + // World entry is the flag that says "repaint those too". + s.run(r#"HonorFrame_OnEvent("PLAYER_ENTERING_WORLD")"#) + .unwrap(); + s.resolve(); + assert_eq!(text(&mut s, "HonorFrameThisWeekHKValue"), "124"); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The rank title against the **real shipped strings**, end to end — the leg the hand-set +/// [`RANK_GLOBALS`] above cannot reach, and the one that proves the key we build +/// (`PVP_RANK__`) is the key the player's own file actually defines. +/// +/// The two sides matter: rank 12 is "Knight-Captain" to the Alliance and "Legionnaire" to the +/// Horde, so a hardcoded team digit passes one and fails the other. Skips without client data. +#[test] +fn the_real_global_strings_name_the_rank() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let src = chain + .read_file("Interface\\FrameXML\\GlobalStrings.lua") + .expect("GlobalStrings.lua in the chain"); + + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.run(&String::from_utf8_lossy(&src)).expect("runs clean"); + load_page(&s); + s.set_honor(Some(state())); + + for (group, want) in [("Alliance", "Knight-Captain"), ("Horde", "Legionnaire")] { + s.set_unit( + "player", + Some(UnitState { + faction_group: Some(group.into()), + ..alliance_player() + }), + ); + s.run(r#"ToggleCharacter("HonorFrame")"#).ok(); + s.run("HonorFrame_Update(1)").unwrap(); + s.resolve(); + assert_eq!( + text(&mut s, "HonorFrameCurrentPVPTitle"), + want, + "rank 12 to the {group}" + ); + } + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +// ── The inspect window's honor page ───────────────────────────────────────────────────────────── +// +// Same twelve rows, same two painters, a different data source: the character page reads a +// descriptor block we hold, this one reads a reply that had to be asked for. The tests below are +// about that difference — the twelve-value tuple landing in the right slots, and the ask/hold latch. + +use benilla_ui::script::InspectHonorData; +use std::collections::HashMap; + +/// The reply for the inspected player, carrying the same figures as [`state`] so a row that took +/// its number from the wrong pane's feed is visible as a wrong *value*, not just a wrong layout. +fn inspect_reply() -> InspectHonorData { + let s = state(); + InspectHonorData { + guid: 0x0000_0001_0000_2AB3, + session_hk: s.session_hk, + session_dk: s.session_dk, + yesterday_hk: s.yesterday_hk, + yesterday_honor: s.yesterday_honor, + this_week_hk: s.this_week_hk, + this_week_honor: s.this_week_honor, + last_week_hk: s.last_week_hk, + last_week_honor: s.last_week_honor, + last_week_standing: s.last_week_standing, + lifetime_hk: s.lifetime_hk, + lifetime_dk: s.lifetime_dk, + // The reply's own `highestRank` — `GetInspectHonorData`'s twelfth return, which the + // reference destructures as `lifetimeRank`. + highest_rank: s.highest_rank, + rank_bar: s.rank_bar, + } +} + +/// The inspect window open on its Honor tab, over a rank-12 Alliance target with [`inspect_reply`] +/// held. +fn shown_inspect_honor_page() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.run(RANK_GLOBALS).unwrap(); + for file in [ + "Fonts.xml", + "UiPanels.xml", + "GameTooltip.xml", + // Before InspectFrame.xml — the honor page inherits this file's row templates and calls + // its two shared painters, and `inherits=` resolves at load (the manifest's own order). + "HonorFrame.xml", + "InspectFrame.xml", + ] { + load_xml(&s, file); + } + s.set_unit("target", Some(alliance_player())); + // The PLAYER snapshot is required even though this page is about somebody else, and the reason + // is the reference's own asymmetry: `BenillaHonorPane_SetRank` passes the inspected token for + // the BAR COLOUR, but `GetPVPRankInfo` takes no unit at all and keys its title off the local + // player's side and sex. That is faithful — the ref calls it the same way — and it is sound + // because the server only lets you inspect a player you cannot attack, i.e. your own faction. + // Without a player pushed, every title on this page reads NONE (found by this test). + s.set_unit("player", Some(alliance_player())); + // 4 yards away (d² = 16), inside the verified `CanInspect` 100.0 — the window refuses to open + // otherwise, and a silently-refused open would read as a broken pane. + s.set_inspect_reach(HashMap::from([("target".to_string(), 16.0)])); + s.set_inspect_honor(Some(inspect_reply())); + s.run(r#"InspectUnit("target")"#).unwrap(); + s.run(r#"ToggleInspect("BenillaInspectHonorFrame")"#) + .unwrap(); + s.resolve(); + s +} + +/// The twelve-value tuple lands in the twelve slots, and the rank block reads the INSPECTED unit — +/// not the player. Everything the character page's row test pins, on the other feed. +#[test] +fn the_inspect_page_paints_the_reply_it_holds() { + let mut s = shown_inspect_honor_page(); + for (frame, want) in [ + ("BenillaInspectHonorFrameCurrentHKValue", "17"), + ("BenillaInspectHonorFrameCurrentDKValue", "2"), + ("BenillaInspectHonorFrameYesterdayHKValue", "41"), + ("BenillaInspectHonorFrameYesterdayContributionValue", "640"), + ("BenillaInspectHonorFrameThisWeekHKValue", "123"), + ("BenillaInspectHonorFrameThisWeekContributionValue", "1250"), + ("BenillaInspectHonorFrameLastWeekHKValue", "420"), + ("BenillaInspectHonorFrameLastWeekContributionValue", "8431"), + ("BenillaInspectHonorFrameLastWeekStandingValue", "57"), + ("BenillaInspectHonorFrameLifeTimeHKValue", "3907"), + ("BenillaInspectHonorFrameLifeTimeDKValue", "12"), + ( + "BenillaInspectHonorFrameLifeTimeRankValue", + "Lieutenant Commander", + ), + ] { + assert_eq!(text(&mut s, frame), want, "{frame}"); + } + // The rank block: the target's CURRENT rank (12 → "Knight-Captain", badge 08), and the bar off + // the reply's own byte rather than the player's. + assert_eq!( + text(&mut s, "BenillaInspectHonorFrameCurrentPVPTitle"), + "Knight-Captain" + ); + assert_eq!( + text(&mut s, "BenillaInspectHonorFrameCurrentPVPRank"), + "(Rank 8)" + ); + let bar = s + .eval::("return BenillaInspectHonorFrameProgressBar:GetValue()") + .unwrap(); + assert!( + (bar - 191.0 / 255.0).abs() < 1e-6, + "the bar reads the REPLY's rank byte, got {bar}" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The latch the reference's `OnShow` gates on: with nothing held the page asks and paints nothing; +/// with a reply held it paints and asks for nothing. +/// +/// This is the one behaviour on this page that is a *round trip* rather than a read, and getting it +/// backwards is invisible on screen in the common case — a window opened on a player whose data +/// happens to be held looks identical either way. +#[test] +fn the_page_asks_only_when_it_holds_nothing() { + let mut s = shown_inspect_honor_page(); + // Held: the open above consumed no request. + assert_eq!( + s.take_inspect_honor_requests(), + 0, + "a page holding a reply must not re-ask" + ); + + // Cleared — the app drops the reply when the inspected player changes. Re-showing the page + // asks, and paints nothing until an answer lands. + s.set_inspect_honor(None); + s.run(r#"BenillaInspectHonorFrame_OnShow()"#).unwrap(); + s.resolve(); + assert_eq!( + s.take_inspect_honor_requests(), + 1, + "a page holding nothing must ask exactly once" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index 4aaf5e33..3096ccda 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -117,6 +117,10 @@ fn armed() -> UiScript { load_xml(&s, "Fonts.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); + // Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits + // HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the + // twelve honor rows materialize bare (decision 1512; the manifest states the same order). + load_xml(&s, "HonorFrame.xml"); load_xml(&s, "InspectFrame.xml"); s.set_unit("target", Some(target_unit())); s.set_inspect(Some(inspect_view("target"))); @@ -132,6 +136,10 @@ fn shipped_inspect_frame_loads_clean() { load_xml(&s, "Fonts.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); + // Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits + // HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the + // twelve honor rows materialize bare (decision 1512; the manifest states the same order). + load_xml(&s, "HonorFrame.xml"); load_xml(&s, "InspectFrame.xml"); // All 19 slots exist and carry their GetInventorySlotInfo id (1..=19, no ammo slot). for (name, id) in [ @@ -396,7 +404,7 @@ fn shipped_inspect_frame_drives_end_to_end() { /// The tab chrome, and where the ref's "toggle the showing page shut" branch actually lives. /// /// `PanelTemplates_SelectTab` **disables** the selected tab button (`UiPanels.xml`, the ref's own -/// UIPanelTemplates), so on a one-tab window the tab is inert from the moment it opens — clicking it +/// UIPanelTemplates), so the SELECTED tab is inert from the moment it opens — clicking it /// cannot close anything. `ToggleInspect`'s close-when-already-showing branch /// (`Blizzard_InspectUI.lua:67-83`) is therefore reachable only by calling `ToggleInspect` directly, /// which is how a keybinding would. Both halves asserted, because the first one is what makes the diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 910a97f2..bfa09247 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -834,6 +834,9 @@ mod skills_frame_tests; #[cfg(test)] mod reputation_frame_tests; +#[cfg(test)] +mod honor_frame_tests; + #[cfg(test)] mod pet_paperdoll_tests; diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 1840e50f..1df41a2f 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -561,6 +561,17 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option, reaction: u8) // 0633's byte-level `[+0xe68]+8` bit-7). Zero on creatures, which have no PLAYER_FLAGS — // and `UnitIsPVPFreeForAll` is false for them in the reference too. is_pvp_ffa: store.0.player_flags() & 0x80 != 0, + // The unit's CURRENT honor rank (decision 1512): `PLAYER_BYTES_3` byte 3, on the internal + // 0..=18 scale. It sits in `snapshot` — not in the caller's guid-keyed enrichment like + // `faction_group` — because it needs nothing but the descriptor, and because it is PUBLIC: + // the server streams it for every player in view, which is the only reason the inspect + // pane's `UnitPVPRank("target")` can answer at all. A creature has no PLAYER block and + // reads 0, the reference's own answer for one. + pvp_rank: store.0.player_pvp_rank().unwrap_or(0), + // `PLAYER_BYTES_3` byte 2 — the city-protector title, the same PUBLIC dword as the rank + // byte above. `UnitPVPName` appends a `PVP_MEDAL` line for a non-zero one; 0 is "no + // medal", which is every character on this server (vmangos never writes the byte). + pvp_medal: store.0.player_pvp_medal().unwrap_or(0), // is_player + the creature-record extras (subtitle/type/rank/civilian) are the caller's // guid-keyed enrichment — [`enrich_unit`]. ..Default::default() @@ -918,13 +929,21 @@ fn feed_units( // art that ships is Alliance/Horde/FFA), and it is indistinguishable on screen from the icon // being broken, which has now cost two separate sessions an investigation. So it is a BENCH // diagnostic, not UI: nothing appears on screen, exactly as in the reference. + // + // The honor arc (1512) put a SECOND surface behind this same side: a rank's title is the + // GlobalString `PVP_RANK__`, and with no side there is no team digit, so + // `GetPVPRankInfo` answers nil and the Honor tab renders `NONE` at every rank — for a Grand + // Marshal. That reads exactly like an unbuilt pane, which is why it is named in the warning + // rather than left for the next investigation to rediscover. if let Some(p) = &player { let sideless = p.faction_group.is_none(); if sideless && !feed.warned_sideless { warn!( "faction: our own template names no side (usually GM mode — vmangos forces \ template 35, group mask 0). Faction-derived UI cannot resolve a side while this \ - holds: the PvP flag icon stays hidden however flagged you are. `.gm off` restores it." + holds: the PvP flag icon stays hidden however flagged you are, and the Honor tab's rank \ + title reads NONE at every rank (PVP_RANK__ has no team digit). \ + `.gm off` restores both." ); } feed.warned_sideless = sideless; diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 92b997d8..d51d3395 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -13,12 +13,13 @@ use crate::messages::{ ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption, GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, - GuildQueryResponse, GuildRoster, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, - LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, - MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, - QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, - QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask, - TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, + GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, + LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, + MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, + PetMode, PetSpells, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, + QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, + TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, + WhoResults, XpGain, }; /// Coarse entity classification, free of wire types so the app can branch on it without depending on @@ -1028,6 +1029,14 @@ pub enum SessionEvent { }, /// Start the duel countdown (`SMSG_DUEL_COUNTDOWN`) — already in whole seconds. DuelCountdown { seconds: u32 }, + /// Another player's honor stats came back (`MSG_INSPECT_HONOR_STATS` reply, decision 1512) — + /// the inspect window's Honor tab. Arrives only in answer to our own ask, and a *refused* ask + /// is answered with silence (the server returns without sending), so a consumer must not + /// treat "no event yet" as "the data is coming". + InspectHonorStats(InspectHonorStats), + /// A kill paid out honor (`SMSG_PVP_CREDIT`) — the honor-gain chat line and floating combat + /// text. Also fires for a **dishonorable** kill, where `honor` is negative. + PvpCredit(PvpCredit), /// One mirror timer started, or was wholly re-stated (`SMSG_START_MIRROR_TIMER`, decision /// 0874) — the breath / fatigue bars. There is no separate update opcode: the server re-sends /// this whole packet whenever direction, remaining time or frozen state changes, so a diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 4519316d..d6a33f5c 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -486,6 +486,8 @@ pub fn decode(packet: ServerPacket) -> Vec { loser, }], ServerPacket::DuelCountdown { seconds } => vec![SessionEvent::DuelCountdown { seconds }], + ServerPacket::InspectHonorStats(stats) => vec![SessionEvent::InspectHonorStats(stats)], + ServerPacket::PvpCredit(credit) => vec![SessionEvent::PvpCredit(credit)], ServerPacket::MirrorTimerStart(start) => vec![SessionEvent::MirrorTimerStart(start)], ServerPacket::MirrorTimerPause { kind, paused } => { vec![SessionEvent::MirrorTimerPause { kind, paused }] diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 56a750cf..d06ff20b 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -45,6 +45,7 @@ mod parse; mod pet; mod pose; mod progression; +mod pvp; mod quest; mod reputation; mod roster; @@ -159,6 +160,7 @@ pub use pet::{ }; pub use pose::{set_sheathed, stand_state_change}; pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain}; +pub use pvp::{inspect_honor_stats, InspectHonorStats, PvpCredit}; pub use quest::{ dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 3bc15962..53802684 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -254,6 +254,11 @@ pub const SMSG_LEVELUP_INFO: u16 = 0x01D4; // 468 /// A newly explored area — area id + the XP it granted (0 at max level). Body /// in [`super::progression::read_exploration_xp`] — decision 0828. pub const SMSG_EXPLORATION_EXPERIENCE: u16 = 0x01F8; // 504 (VERIFIED vmangos `Opcodes_1_12_1.h:505`) +/// The honor payout (VERIFIED vmangos `Opcodes_1_12_1.h`: 652) — the XP-gain line's PvP twin, and +/// the only attributed notice of an honor award: the descriptor's contribution fields move too, +/// but they are running totals. Sent for **dishonorable** kills as well, carrying negative honor. +/// Body in [`super::pvp::read_pvp_credit`] ([`super::PvpCredit`]) — decision 1512. +pub const SMSG_PVP_CREDIT: u16 = 0x028C; // 652 // The combat-log wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 464/587/590/591/592) — decision 0137 // phase 2's floating-combat-text data feed. Bodies in [`super::spells`]. @@ -471,6 +476,16 @@ pub const CMSG_CANCEL_CHANNELLING: u16 = 0x013B; // 315 /// streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields without waiting on it, and no FrameXML handler /// registers an inspect event. Decision 0631. pub const CMSG_INSPECT: u16 = 0x0114; // 276 +/// 726 (VERIFIED vmangos `Opcodes_1_12_1.h`) — the inspect window's **Honor tab**, and an `MSG_`: +/// the one opcode number carries both directions. Our request is a raw 8-byte guid +/// ([`super::inspect_honor_stats`]); the server's reply, on the same number, is the 50-byte +/// [`super::InspectHonorStats`] body. Nothing needs to disambiguate the two shapes — direction +/// does: [`super::parse_server`] only ever sees inbound bodies, so a 0x2D6 there is always the +/// reply, and our outbound body never passes through it. +/// +/// Same three silent refusals as [`CMSG_INSPECT`] (`MiscHandler.cpp:962-972`), but *unlike* it +/// this handler does not set our selection. Decision 1512. +pub const MSG_INSPECT_HONOR_STATS: u16 = 0x02D6; // 726 pub const CMSG_SET_SELECTION: u16 = 0x013D; // 317 pub const CMSG_ATTACKSWING: u16 = 0x0141; // 321 pub const CMSG_ATTACKSTOP: u16 = 0x0142; // 322 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 3e556a35..ac192c98 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -9,13 +9,14 @@ use super::{ CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, - GuildRoster, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, - LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, - Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails, - QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, - ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog, - SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus, - TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, + GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, + LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, + MirrorTimerStart, MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, + PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, + QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets, + SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, + SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, + VendorItem, WhoResults, XpGain, }; /// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this @@ -983,6 +984,14 @@ pub enum ServerPacket { DuelCountdown { seconds: u32, }, + /// The `MSG_INSPECT_HONOR_STATS` **reply** — another player's honor stats (decision 1512). + /// The opcode is an `MSG_`, so the same number carries our request; direction disambiguates + /// (see [`super::opcode::MSG_INSPECT_HONOR_STATS`]). Unsolicited replies do not happen: this + /// only ever arrives because we asked, and a refused ask is answered with silence. + InspectHonorStats(InspectHonorStats), + /// `SMSG_PVP_CREDIT` — an honor payout, the only *attributed* notice of one. Also sent for + /// dishonorable kills, with negative honor. + PvpCredit(PvpCredit), /// `SMSG_START_MIRROR_TIMER` — start or wholly re-state one mirror timer (the breath / /// fatigue bars, decision 0874). The family has no update opcode: every change re-sends this. MirrorTimerStart(MirrorTimerStart), @@ -1351,6 +1360,8 @@ impl ServerPacket { ServerPacket::DuelComplete { .. } => "SMSG_DUEL_COMPLETE".into(), ServerPacket::DuelWinner { .. } => "SMSG_DUEL_WINNER".into(), ServerPacket::DuelCountdown { .. } => "SMSG_DUEL_COUNTDOWN".into(), + ServerPacket::InspectHonorStats(..) => "MSG_INSPECT_HONOR_STATS".into(), + ServerPacket::PvpCredit(..) => "SMSG_PVP_CREDIT".into(), ServerPacket::MirrorTimerStart(..) => "SMSG_START_MIRROR_TIMER".into(), ServerPacket::MirrorTimerPause { .. } => "SMSG_PAUSE_MIRROR_TIMER".into(), ServerPacket::MirrorTimerStop { .. } => "SMSG_STOP_MIRROR_TIMER".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 8ffad0d8..40953063 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -13,9 +13,9 @@ use crate::wire::{ use super::{ action_bar, area_trigger, attack, bank, binder, channel, chat, combat_log, death, duel, gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move, movement, - opcode, page_text, pet, progression, quest, social, spellbook, spells, taxi, trade, trainer, - update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket, - SpeedKind, + opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, taxi, trade, + trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode, + ServerPacket, SpeedKind, }; /// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`, @@ -964,6 +964,16 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { opcode::SMSG_DUEL_COUNTDOWN => ServerPacket::DuelCountdown { seconds: duel::read_duel_countdown(&mut r)?, }, + // The honor pair (decision 1512; bodies in `pvp`). MSG_INSPECT_HONOR_STATS is an `MSG_`: + // opcode 0x2D6 carries our 8-byte request AND the server's 50-byte reply. There is no + // ambiguity to resolve here — `parse_server` is fed only by the world *reader*, so an + // 0x2D6 body arriving at this function is always the reply. The request shape is built by + // `pvp::inspect_honor_stats` and goes straight out through `world::writer::pvp`; it never + // reaches this match. + opcode::MSG_INSPECT_HONOR_STATS => { + ServerPacket::InspectHonorStats(pvp::read_inspect_honor_stats(&mut r)?) + } + opcode::SMSG_PVP_CREDIT => ServerPacket::PvpCredit(pvp::read_pvp_credit(&mut r)?), // The mirror timers (decision 0874; bodies in `mirror_timer`). START carries the timer's // whole state and is re-sent on every change — there is no update opcode in the family. opcode::SMSG_START_MIRROR_TIMER => { diff --git a/crates/benilla-protocol/src/messages/pvp.rs b/crates/benilla-protocol/src/messages/pvp.rs new file mode 100644 index 00000000..07324082 --- /dev/null +++ b/crates/benilla-protocol/src/messages/pvp.rs @@ -0,0 +1,368 @@ +//! The two honor-system wire messages (decision 1512): the inspect-honor request/reply pair and +//! the honor-gain credit. Every shape below is VERIFIED against vmangos source (file:line cites +//! per item); the goldens are hand-built against the server's own `AppendBodyTo` order. +//! +//! The *durable* honor state is not here — it rides the player descriptor, eleven PRIVATE fields +//! plus the PUBLIC current-rank byte ([`crate::messages::ObjectFields::player_pvp_rank`] and +//! friends). These two packets cover exactly what the descriptor cannot: **somebody else's** +//! honor stats (which never stream to us), and the *moment* a kill pays out. +//! +//! Not modelled here (known siblings, VERIFIED present in vmangos `Opcodes_1_12_1.h`): the +//! battleground family — `MSG_PVP_LOG_DATA` (736), `CMSG`/`SMSG_BATTLEFIELD_LIST` (572/573) and +//! `…_STATUS` (723/724) — which 0208/0631 defer wholesale, and `SMSG_ZONE_UNDER_ATTACK` (596), a +//! world broadcast rather than an honor message. + +use std::io; + +use crate::wire::{read_i32_le, read_u16_le, read_u32_le, read_u64_le, read_u8}; + +/// Another player's honor stats — the `MSG_INSPECT_HONOR_STATS` **reply** (opcode `0x2D6`/726, +/// VERIFIED vmangos `Opcodes_1_12_1.h`; already named in `opcode_names`). A 50-byte body, all +/// little-endian with no padding, in the exact order +/// `WorldPackets::Misc::InspectHonorStatsResponse::AppendBodyTo` writes it +/// (`Server/Packets/Misc.cpp:301-325`). Every `#if SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1` +/// block in that function is live for build 5875, so all three conditional members are present. +/// +/// This exists because the honor descriptor fields are PRIVATE: nothing about a *foreign* player's +/// kills, honor or progress bar is on the wire until we ask. The one exception is their current +/// rank, which streams publicly in `PLAYER_BYTES_3` and so is **not** in this packet — +/// [`crate::messages::ObjectFields::player_pvp_rank`] answers it off the target's own descriptor. +/// +/// The server builds every value straight out of the target's descriptor +/// (`Handlers/MiscHandler.cpp:962-1010`), so these fields are the inspected player's own +/// `PLAYER_FIELD_*` honor block, re-serialized — with two shape differences worth knowing: +/// `sessionKills` arrives as the raw packed dword rather than a split pair, and the three weekly +/// HK counts arrive as bare `u16`s with a separate (dead) dishonorable slot beside each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InspectHonorStats { + /// Whose stats these are — echoed from our request (`MiscHandler.cpp:977`). The reply carries + /// no error shape at all: the server simply does not answer when the target is missing, out of + /// `INSPECT_DISTANCE` (10 yd), or a valid attack target, so a silent non-reply is the refusal. + pub player_guid: u64, + /// The target's **highest lifetime** honor rank (`GetHighestRank().rank`, + /// `MiscHandler.cpp:985`) — the *internal* rank 0..18, the same domain as + /// [`crate::messages::ObjectFields::player_honor_rank`], NOT the visual number the UI draws. + /// Their *current* rank is the PUBLIC descriptor byte, not this. + pub highest_rank: u8, + /// Today's kills as the raw packed `PLAYER_FIELD_SESSION_KILLS` dword — low `u16` honorable, + /// high `u16` dishonorable (`MiscHandler.cpp:988` reads the whole field with + /// `GetUInt32Value`). Split with [`InspectHonorStats::session_kills`]. + pub session_kills: u32, + /// Yesterday's honorable kills (`GetUInt16Value(PLAYER_FIELD_YESTERDAY_KILLS, 0)`). + pub yesterday_hk: u16, + /// Written `0` by vmangos (`MiscHandler.cpp:994`, comment *"Unknown (deprecated, yesterday + /// dishonourable?)"*). Decoded and carried rather than skipped: whether the real client reads + /// these three slots at all is a question currently out with an RE orchestrator in + /// `wow-5875-re`, and a field we decode and ignore is honest where a silently-skipped one + /// would be a landmine if the verdict comes back "it reads them". + pub unknown_old1: u16, + /// Last week's honorable kills. + pub last_week_hk: u16, + /// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`]. + pub unknown_old2: u16, + /// This week's honorable kills (a 1.6.0 addition; live for 5875). + pub this_week_hk: u16, + /// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`]. + pub unknown_old3: u16, + /// Lifetime honorable kills (`PLAYER_FIELD_LIFETIME_HONORBALE_KILLS`, vmangos's spelling). + pub lifetime_hk: u32, + /// Lifetime dishonorable kills. + pub lifetime_dhk: u32, + /// Yesterday's honor points (`PLAYER_FIELD_YESTERDAY_CONTRIBUTION`). + pub yesterday_honor: u32, + /// Last week's honor points. + pub last_week_honor: u32, + /// This week's honor points (a 1.6.0 addition; live for 5875). + pub this_week_honor: u32, + /// Last week's **standing** — the numeric ladder position, not a rank + /// (`PLAYER_FIELD_LAST_WEEK_RANK`; `0` = unranked that week). + pub last_week_rank: u32, + /// The rank progress bar, `0..255` **within the target's current rank** (a 1.6.0 addition; + /// live for 5875). See [`crate::messages::ObjectFields::player_honor_rank_bar`] for the + /// server's computation and the negative-rank wrap. + pub rank_bar: u8, +} + +impl InspectHonorStats { + /// [`Self::session_kills`] split into `(honorable, dishonorable)` — the `TWO_SHORT` packing + /// the descriptor field uses (low half, then high half). The wire carries the dword whole + /// here, so unlike the descriptor accessor this split is ours to do. + pub fn session_kills(&self) -> (u16, u16) { + (self.session_kills as u16, (self.session_kills >> 16) as u16) + } +} + +/// Read the `MSG_INSPECT_HONOR_STATS` **reply** (see [`InspectHonorStats`]): 50 bytes in +/// `AppendBodyTo` order. A short body errors (`UnexpectedEof`) rather than yielding a +/// half-populated struct. +/// +/// The opcode is an **`MSG_`**: the same number 0x2D6 carries our request *and* the server's +/// reply, and the two bodies are different shapes (8 bytes vs 50). Nothing has to disambiguate +/// them, because direction does it: [`super::parse_server`] is fed only by the world *reader*, so +/// every 0x2D6 body it ever sees is a reply. Our own request never passes through here — it is +/// built by [`inspect_honor_stats`] and handed straight to the writer. +pub(super) fn read_inspect_honor_stats(r: &mut &[u8]) -> io::Result { + Ok(InspectHonorStats { + player_guid: read_u64_le(r)?, + highest_rank: read_u8(r)?, + session_kills: read_u32_le(r)?, + yesterday_hk: read_u16_le(r)?, + unknown_old1: read_u16_le(r)?, + last_week_hk: read_u16_le(r)?, + unknown_old2: read_u16_le(r)?, + this_week_hk: read_u16_le(r)?, + unknown_old3: read_u16_le(r)?, + lifetime_hk: read_u32_le(r)?, + lifetime_dhk: read_u32_le(r)?, + yesterday_honor: read_u32_le(r)?, + last_week_honor: read_u32_le(r)?, + this_week_honor: read_u32_le(r)?, + last_week_rank: read_u32_le(r)?, + rank_bar: read_u8(r)?, + }) +} + +/// Body of the `MSG_INSPECT_HONOR_STATS` **request**: the inspected player's full 8-byte GUID and +/// nothing else (VERIFIED vmangos `WorldPackets::Misc::InspectHonorStats::ReadFromWorldPacket`, +/// `Server/Packets/Misc.cpp:93-96` — `recv_data >> guid`, a raw `uint64`, not a packed guid). +/// +/// Server gates (`MiscHandler.cpp:962-972`) are the same three `CMSG_INSPECT` applies: the target +/// must be an online player, within `INSPECT_DISTANCE` (10.0), and not a valid attack target. Each +/// refusal is a silent `return` — there is no error reply, so a consumer must not block on an +/// answer that may never come. Unlike `CMSG_INSPECT`, this handler does **not** set our selection +/// (`HandleInspectOpcode` opens with `SetSelectionGuid`; this one does not), which is why the send +/// lives in `world::writer::pvp` rather than `writer::selection`. +pub fn inspect_honor_stats(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// An honor payout (`SMSG_PVP_CREDIT`, opcode `0x28C`/652 — VERIFIED vmangos +/// `Opcodes_1_12_1.h`; already named in `opcode_names`). A 16-byte body in the order +/// `WorldPackets::Misc::PvpCredit::AppendBodyTo` writes it (`Server/Packets/Misc.cpp:376-381`), +/// sent from `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1061-1093`) on every honor row the server +/// books — **including a dishonorable one**, which is why [`PvpCredit::honor`] is signed. +/// +/// This is the *only* notice of a specific payout: the descriptor's contribution fields move too, +/// but they are running totals with no attribution. The chat line and the floating combat text +/// both have to come from here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PvpCredit { + /// Honor points, truncated to an integer server-side (`static_cast(honor)`). + /// + /// **Signed, and genuinely negative for a dishonorable kill**: `HonorMgr::Add` computes + /// `honor = (type == DISHONORABLE) ? -cp : cp` (`HonorMgr.cpp:807`) and hands *that* to + /// `SendPVPCredit` (`HonorMgr.cpp:833`). A `u32` here would read a DK penalty as ~4 billion + /// honor. + pub honor: i32, + /// Who we killed. **`0` means there is no victim** — vmangos only fills the guid inside + /// `if (victim)` (`HonorMgr.cpp:1069-1071`), and it passes the *pre-fallback* `realSource`, so + /// an honor row booked with no source at all arrives with a zero guid. The UI needs a + /// victim-less phrasing rather than resolving a name for guid 0. + pub victim_guid: u64, + /// The victim's **internal** rank — the same `[0..18]` domain as the descriptor's rank bytes + /// and [`InspectHonorStats::highest_rank`], and therefore a direct index into the + /// `PVP_RANK__` GlobalStrings. NOT the visual rank the badge texture uses; do the + /// `internal > 4 ? internal - 4 : -internal` conversion (`HonorMgr.cpp:991`) at the display + /// edge if a badge is wanted, and do not do it twice. + /// + /// VERIFIED `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1078-1089`): a **player** victim sends + /// `GetHonorMgr().GetRank().rank`, a **creature** victim sends `19` iff it is a racial leader + /// and otherwise leaves the field at its `0` default. + /// + /// One quirk to observe rather than imitate: for a player victim vmangos **floors the value at + /// 5** (`if (!rank) rank = (HONOR_RANK_COUNT - POSITIVE_HONOR_RANK_COUNT) + 1`, i.e. "at least + /// Scout"), with the comment *"Never display just 'HK:' without rank name"*. That is the + /// server working around a client that would otherwise print an empty rank name — it is + /// **server behaviour we receive**, not a rule we should re-implement: we render whatever + /// number arrives, and an unranked-victim `0` from some other server must still come out + /// sanely at the display edge. + /// + /// `i32` because that is the wire type; nothing vmangos sends here is negative. + pub victim_rank: i32, +} + +/// Read `SMSG_PVP_CREDIT` (see [`PvpCredit`]): `i32 honor`, `u64 victimGuid`, `i32 victimRank`. +pub(super) fn read_pvp_credit(r: &mut &[u8]) -> io::Result { + Ok(PvpCredit { + honor: read_i32_le(r)?, + victim_guid: read_u64_le(r)?, + victim_rank: read_i32_le(r)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The 50-byte reply, hand-built in `InspectHonorStatsResponse::AppendBodyTo` order with the + /// field boundaries commented — a level-60 target with a rank-11 lifetime best, kills in every + /// bucket, and a bar three-quarters of the way through the current rank. + #[test] + fn inspect_honor_stats_golden() { + #[rustfmt::skip] + let body: [u8; 50] = [ + // [0..8) u64 playerGuid = 0x0000_0001_0000_2AB3 + 0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + // [8] u8 highestRank = 11 (internal) + 0x0B, + // [9..13) u32 sessionKills = 0x0002_0011 — 17 HK low, 2 DK high + 0x11, 0x00, 0x02, 0x00, + // [13..15) u16 yesterdayHK = 41 + 0x29, 0x00, + // [15..17) u16 unknownOld1 = 0 (server always writes 0) + 0x00, 0x00, + // [17..19) u16 lastWeekHK = 420 + 0xA4, 0x01, + // [19..21) u16 unknownOld2 = 0 + 0x00, 0x00, + // [21..23) u16 thisWeekHK = 123 + 0x7B, 0x00, + // [23..25) u16 unknownOld3 = 0 + 0x00, 0x00, + // [25..29) u32 lifetimeHK = 3907 + 0x43, 0x0F, 0x00, 0x00, + // [29..33) u32 lifetimeDHK = 12 + 0x0C, 0x00, 0x00, 0x00, + // [33..37) u32 yesterdayHonor = 640 + 0x80, 0x02, 0x00, 0x00, + // [37..41) u32 lastWeekHonor = 8431 + 0xEF, 0x20, 0x00, 0x00, + // [41..45) u32 thisWeekHonor = 1250 + 0xE2, 0x04, 0x00, 0x00, + // [45..49) u32 lastWeekRank = 57 (the STANDING) + 0x39, 0x00, 0x00, 0x00, + // [49] u8 rankBar = 191 + 0xBF, + ]; + let mut r = body.as_slice(); + let stats = read_inspect_honor_stats(&mut r).unwrap(); + assert!(r.is_empty(), "the whole 50-byte body is consumed"); + assert_eq!( + stats, + InspectHonorStats { + player_guid: 0x0000_0001_0000_2AB3, + highest_rank: 11, + session_kills: 0x0002_0011, + yesterday_hk: 41, + unknown_old1: 0, + last_week_hk: 420, + unknown_old2: 0, + this_week_hk: 123, + unknown_old3: 0, + lifetime_hk: 3_907, + lifetime_dhk: 12, + yesterday_honor: 640, + last_week_honor: 8_431, + this_week_honor: 1_250, + last_week_rank: 57, + rank_bar: 191, + } + ); + // The packed today-kills dword splits the same way the descriptor's TWO_SHORT does. + assert_eq!(stats.session_kills(), (17, 2)); + } + + /// A truncated reply must ERROR, not panic and not yield a half-populated struct — the wire is + /// hostile input. 49 bytes is the nastiest case: everything but the trailing `rankBar`, which + /// is exactly what a pre-1.6.1 server would send. + #[test] + fn inspect_honor_stats_truncated_body_errors() { + let full = [0u8; 50]; + for len in [0usize, 8, 13, 49] { + let mut r = &full[..len]; + assert!( + read_inspect_honor_stats(&mut r).is_err(), + "a {len}-byte inspect-honor body must be rejected" + ); + } + // …and the full length still parses, so the loop above is testing truncation and not a + // decoder that rejects everything. + let mut r = full.as_slice(); + assert!(read_inspect_honor_stats(&mut r).is_ok()); + } + + /// The request is the bare 8-byte guid — byte-exact against + /// `InspectHonorStats::ReadFromWorldPacket`'s single `recv_data >> guid`. + #[test] + fn inspect_honor_stats_request_golden() { + assert_eq!( + inspect_honor_stats(0x0000_0001_0000_2AB3), + vec![0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00], + ); + // No sentinel handling: a zero guid is encoded as-is (the server answers by not answering). + assert_eq!(inspect_honor_stats(0), vec![0u8; 8]); + } + + /// The 16-byte credit body, hand-built in `PvpCredit::AppendBodyTo` order. + #[test] + fn pvp_credit_golden() { + #[rustfmt::skip] + let body: [u8; 16] = [ + // [0..4) i32 honor = 143 + 0x8F, 0x00, 0x00, 0x00, + // [4..12) u64 victimGuid = 0x0000_0001_0000_2AB3 + 0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + // [12..16) i32 victimRank = 11 (INTERNAL rank — the PVP_RANK_11_ key) + 0x0B, 0x00, 0x00, 0x00, + ]; + let mut r = body.as_slice(); + let credit = read_pvp_credit(&mut r).unwrap(); + assert!(r.is_empty(), "the whole 16-byte body is consumed"); + assert_eq!( + credit, + PvpCredit { + honor: 143, + victim_guid: 0x0000_0001_0000_2AB3, + victim_rank: 11, + } + ); + } + + /// A **dishonorable** kill rides the same packet with a negative `honor` + /// (`HonorMgr.cpp:807`, `honor = -cp`) — the reason the field is `i32`. Decoded as `u32` this + /// body would read as 4 294 967 291 honor. + #[test] + fn pvp_credit_dishonorable_kill_is_negative_honor() { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&(-5i32).to_le_bytes()); + body.extend_from_slice(&0x0000_0001_0000_2AB3u64.to_le_bytes()); + body.extend_from_slice(&5i32.to_le_bytes()); // floored to "Scout" server-side + let mut r = body.as_slice(); + let credit = read_pvp_credit(&mut r).unwrap(); + assert!(r.is_empty()); + assert_eq!(credit.honor, -5); + assert_eq!(credit.victim_rank, 5); + } + + /// The victim-less shape the UI has to phrase differently: vmangos fills the guid only inside + /// `if (victim)` and passes the pre-fallback `realSource`, so an honor row with no source + /// arrives as a zero guid with the rank left at its `0` default (which is also what a + /// non-racial-leader creature victim sends). + #[test] + fn pvp_credit_zero_guid_means_no_victim() { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&5i32.to_le_bytes()); + body.extend_from_slice(&0u64.to_le_bytes()); // no victim + body.extend_from_slice(&0i32.to_le_bytes()); // and so no rank + let mut r = body.as_slice(); + let credit = read_pvp_credit(&mut r).unwrap(); + assert!(r.is_empty()); + assert_eq!(credit.victim_guid, 0, "zero guid = no victim"); + assert_eq!(credit.victim_rank, 0); + } + + /// A truncated credit body errors rather than panicking. + #[test] + fn pvp_credit_truncated_body_errors() { + let full = [0u8; 16]; + for len in [0usize, 4, 12, 15] { + let mut r = &full[..len]; + assert!( + read_pvp_credit(&mut r).is_err(), + "a {len}-byte credit body must be rejected" + ); + } + let mut r = full.as_slice(); + assert!(read_pvp_credit(&mut r).is_ok()); + } +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 29c978fb..b394f0d6 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -261,6 +261,14 @@ const FIELD_PLAYER_BYTES_2: u16 = 194; // PLAYER_BYTES_3 = UNIT_END(188) + 0x7: the low u16 packs `gender | (drunk & 0xFFFE)` (vmangos // `Player::SetDrunkValue`), so byte 1 is the drunk value's high byte — exactly the byte the // reference client reads at `[[unit+0xe68]+0x1d]` (wow-re `drunk_fraction_5e2a90`). +// Byte 2 = the city-protector title (a race id), byte 3 = the **current** honor rank +// (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos `Player.h:351-356`). +// +// **PUBLIC** (VERIFIED vmangos `UpdateFields_1_12_1.h:125`) — and that flag is the whole reason a +// foreign player's rank is knowable: this is the ONE honor value that streams for every visible +// player. The rest of the honor block (1250..1260 below, plus `PLAYER_FIELD_BYTES`' highest-rank +// byte) is PRIVATE, which is why anyone else's honor stats have to be *asked* for over the wire +// (`MSG_INSPECT_HONOR_STATS`, [`crate::messages::InspectHonorStats`]). const FIELD_PLAYER_BYTES_3: u16 = 195; // PLAYER_QUEST_LOG_1_1 = UNIT_END(188) + 0xA = 198 (vmangos `UpdateFields_1_12_1.h:128`; this block // precedes the CONTAINER-onward hex-comment drift — enum arithmetic and hex comment agree here, and @@ -476,6 +484,50 @@ const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A, BYTES — vmangos // flags / comboPoints / actionBars / HIGHEST_HONOR_RANK // (`Player.h` PLAYER_FIELD_BYTES_OFFSET_*). +// The **honor block** — the eleven fields the two PvP/honor panes read, a contiguous run +// UNIT_END(188) + 0x426..0x430 (VERIFIED vmangos `UpdateFields_1_12_1.h:288-298` **enum +// arithmetic**; its inline `// 0x4DC`-style comments carry the usual 6-low drift and are not +// copied). The run needs no fresh anchor of its own: it closes against already-tested neighbours +// at BOTH ends — the 12-slot `BUYBACK_TIMESTAMP_1` array ends exactly where it begins +// (1238 + 12 = 1250) and `WATCHED_FACTION_INDEX`, itself chain-locked to the tested COINAGE, sits +// exactly one past its end (1260 + 1 = 1261). Eleven fields is precisely the size of that gap. +// +// **Every one of them is PRIVATE** — the server sends them only on our OWN descriptor — and that +// is the mechanical reason the character window's Honor tab works for the local player and for +// nobody else. A foreign player's honor stats are not on the wire at all until we ask for them +// with `MSG_INSPECT_HONOR_STATS` ([`crate::messages::InspectHonorStats`]); the sole honor value +// that *does* stream for every visible player is the current rank in the PUBLIC +// `PLAYER_BYTES_3`(195) byte 3 above. +// +// Types, in run order — `+0x426..0x429` TWO_SHORT, `+0x42A..0x42F` INT, `+0x430` BYTES: +// +// The four kill counters are TWO_SHORT, `(honorable, dishonorable)` in the low/high halves. Only +// SESSION_KILLS is genuinely written as two halves (vmangos `HonorMgr.cpp:913-914`, +// `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`); the other three go out as a whole dword +// (`SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`, `HonorMgr.cpp:917/924/929`), so +// their high half is always 0 from this server. The descriptor type is TWO_SHORT regardless and +// the real client reads halves, so we decode halves — see the accessors in `player.rs`. +// +// The six INT fields are the three honor-point "contribution" totals, the two lifetime kill +// counts, and LAST_WEEK_RANK — which is the weekly **standing** (our ladder position), not a rank. +// All six are `SetUInt32Value` writes from `HonorMgr::Update` (`HonorMgr.cpp:917-935`). Our two +// lifetime names fix vmangos's own typo: the header spells them +// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…DISHONORBALE…`. +// +// BYTES2's byte 0 is `HONOR_RANK_BAR` (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos +// `Player.h:366-372`); its bytes 1..3 are a flags byte and two unknowns this server never writes. +const FIELD_PLAYER_FIELD_SESSION_KILLS: u16 = 1250; +const FIELD_PLAYER_FIELD_YESTERDAY_KILLS: u16 = 1251; +const FIELD_PLAYER_FIELD_LAST_WEEK_KILLS: u16 = 1252; +const FIELD_PLAYER_FIELD_THIS_WEEK_KILLS: u16 = 1253; +const FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION: u16 = 1254; +const FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS: u16 = 1255; +const FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS: u16 = 1256; +const FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION: u16 = 1257; +const FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION: u16 = 1258; +const FIELD_PLAYER_FIELD_LAST_WEEK_RANK: u16 = 1259; +const FIELD_PLAYER_FIELD_BYTES2: u16 = 1260; + /// The number of `PLAYER_SKILL_INFO` slots the descriptor reserves (384 fields ÷ 3 per slot; /// vmangos `UpdateFields_1_12_1.h`). vmangos itself only ever *populates* `PLAYER_MAX_SKILLS` (127, /// `Player.h:69`) of these — the last reserved slot is never written; `player_skill` honors the diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index eff463da..7cfe9650 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -560,10 +560,148 @@ impl ObjectFields { /// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's /// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the /// values byte at `+0x102b`). `None` before the field streams; rank 0 = never ranked. + /// + /// **Not [`Self::player_pvp_rank`]** — that is the *current* rank, a different byte of a + /// different (and PUBLIC) field, and the two diverge the moment a rank is lost: vmangos only + /// ever raises this one (`HonorMgr.cpp:894-895`, `SetHighestRank` iff the new visual rank is + /// strictly greater), so a demoted player keeps their old title here while the current rank + /// falls. The Honor pane shows the current rank; an item requirement reads this one. pub fn player_honor_rank(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_BYTES) .map(|b| ((b >> 24) & 0xff) as u8) } + /// `PLAYER_BYTES_3` byte 3 — the player's **current** honor rank (vmangos + /// `PLAYER_BYTES_3_OFFSET_HONOR_RANK`, written by `HonorMgr::Update`, `HonorMgr.cpp:900`). + /// The *internal* rank — `HonorRankInfo::rank`, whose own comment reads "internal range + /// [0..18]": 0 = unranked, 1..4 the negative ranks (Pariah…Dishonored), 5 up the positive ones + /// (Scout/Private…). It indexes the `PVP_RANK__` GlobalStrings directly; the + /// number the UI *draws* is the **visual** rank (`rank > 4 ? rank - 4 : -rank`, + /// `HonorMgr.cpp:991`), which is a derivation, not a field — do it at the display edge, not + /// here, and only once. + /// + /// **This field is PUBLIC** (vmangos `UpdateFields_1_12_1.h:125`), the only honor value that + /// is: it streams for *every visible player*, not just for us. That is what makes a foreign + /// unit's rank answerable at all — the reference inspect pane's `UnitPVPRank("target")` reads + /// this byte off the target's own descriptor, with no packet involved. Everything else on the + /// Honor tab is PRIVATE and has to be requested + /// ([`crate::messages::read_inspect_honor_stats`]). + /// + /// Contrast [`Self::player_honor_rank`], the *highest lifetime* rank in a PRIVATE field's + /// byte 3; [`Self::player_drunk_byte`] reads byte 1 of this same dword. `None` before the + /// field streams. + pub fn player_pvp_rank(&self) -> Option { + self.get_u32(FIELD_PLAYER_BYTES_3) + .map(|b| ((b >> 24) & 0xff) as u8) + } + /// `PLAYER_BYTES_3` **byte 2** — the **city-protector title** + /// (`PLAYER_BYTES_3_OFFSET_CITY_PROTECTOR_TITLE`, vmangos `Player.h:354`, whose own comment + /// says the value is a **race id**). PUBLIC, like [`Self::player_pvp_rank`] beside it in the + /// same dword. + /// + /// Its one consumer is `UnitPVPName`, which appends `"\n"` plus the `PVP_MEDAL` + /// GlobalString when the byte is non-zero (wow-re `system/ui/scratch/honor-panel-law.md`, + /// `0x609370` leg C) — the "Protector of Stormwind" line under a city defender's name. + /// + /// **vmangos never writes it**, so it is 0 for every character on this server and the medal + /// line never renders. Decoded anyway: the field is real, the binding that reads it is real, + /// and a decoder that answered `None` here would make a live divergence look like an unbuilt + /// feature the day a server does write it. `None` before the field streams. + pub fn player_pvp_medal(&self) -> Option { + self.get_u32(FIELD_PLAYER_BYTES_3) + .map(|b| ((b >> 16) & 0xff) as u8) + } + /// `PLAYER_FIELD_SESSION_KILLS` — **today's** `(honorable, dishonorable)` kills, low half then + /// high half. The one honor kill counter vmangos genuinely writes as two shorts + /// (`HonorMgr.cpp:913-914`, `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`), so both + /// halves are live here. PRIVATE — self only; `None` before the field streams. + /// + /// Despite the name the server recomputes it from *today's* honor-CP rows, not from + /// session start (`HonorMgr::Update`'s `itr.date == today` arm) — the reference's + /// `GetPVPSessionStats()` pair. + pub fn player_session_kills(&self) -> Option<(u16, u16)> { + self.get_u16_pair(FIELD_PLAYER_FIELD_SESSION_KILLS) + } + /// `PLAYER_FIELD_YESTERDAY_KILLS` — `(honorable, dishonorable)` kills for yesterday. + /// + /// **The dishonorable half is always `0` from this server, and that is not a decode bug.** + /// The descriptor type really is TWO_SHORT and the real client really does read halves, but + /// vmangos writes the field as a whole dword (`HonorMgr.cpp:917`, + /// `SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`) — so the high half never + /// receives the DK count. We decode the wire faithfully rather than collapsing the field to a + /// `u32`: the shape belongs to the descriptor, and a different server (or a fixed vmangos) + /// would fill it. Same for [`Self::player_last_week_kills`] and + /// [`Self::player_this_week_kills`]. PRIVATE — self only; `None` before the field streams. + pub fn player_yesterday_kills(&self) -> Option<(u16, u16)> { + self.get_u16_pair(FIELD_PLAYER_FIELD_YESTERDAY_KILLS) + } + /// `PLAYER_FIELD_LAST_WEEK_KILLS` — `(honorable, dishonorable)` kills for last week. The + /// high half is always `0` from vmangos (`HonorMgr.cpp:929` writes the whole dword); see + /// [`Self::player_yesterday_kills`]. PRIVATE — self only. + pub fn player_last_week_kills(&self) -> Option<(u16, u16)> { + self.get_u16_pair(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS) + } + /// `PLAYER_FIELD_THIS_WEEK_KILLS` — `(honorable, dishonorable)` kills for the current week. + /// The high half is always `0` from vmangos (`HonorMgr.cpp:924` writes the whole dword); see + /// [`Self::player_yesterday_kills`]. PRIVATE — self only. + pub fn player_this_week_kills(&self) -> Option<(u16, u16)> { + self.get_u16_pair(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS) + } + /// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION` — honor points earned this week (vmangos's + /// "contribution": the summed CP of the week's honor rows, floored at 0 — + /// `HonorMgr.cpp:925`). PRIVATE — self only; `None` before the field streams. + pub fn player_this_week_contribution(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION) + } + /// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` — honor points earned yesterday + /// (`HonorMgr.cpp:918`). PRIVATE — self only. + pub fn player_yesterday_contribution(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION) + } + /// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION` — honor points earned last week + /// (`HonorMgr.cpp:930`). This is the figure the weekly rank calculation actually consumed. + /// PRIVATE — self only. + pub fn player_last_week_contribution(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION) + } + /// `PLAYER_FIELD_LAST_WEEK_RANK` — last week's **standing**, i.e. our numeric position on the + /// server-wide honor ladder for that week (vmangos `m_standing`, `HonorMgr.cpp:931`), NOT an + /// honor rank. `0` means "unranked that week". PRIVATE — self only. + pub fn player_last_week_rank(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_RANK) + } + /// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` (vmangos's spelling) — the lifetime honorable-kill + /// total (`HonorMgr.cpp:934`). PRIVATE — self only; `None` before the field streams. + pub fn player_lifetime_honorable_kills(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS) + } + /// `PLAYER_FIELD_LIFETIME_DISHONORBALE_KILLS` (vmangos's spelling) — the lifetime + /// dishonorable-kill total (`HonorMgr.cpp:935`). PRIVATE — self only. + pub fn player_lifetime_dishonorable_kills(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS) + } + /// `PLAYER_FIELD_BYTES2` byte 0 — the **honor rank progress bar** + /// (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos `HonorMgr.cpp:905-909`): + /// + /// ```text + /// honorBar = uint32(|rankPoints|); + /// honorBar = uint8(((honorBar - rank.minRP) / (rank.maxRP - rank.minRP)) + /// * (rank.positive ? 255 : -255)); + /// ``` + /// + /// so the byte is **how far through the CURRENT rank we are, scaled 0..255** — not an absolute + /// honor total, and it resets each time the rank changes (`minRP`/`maxRP` are that rank's own + /// bounds, `HonorMgr::CalculateRankInfo`). A *negative* rank multiplies by `-255`, and the + /// C++ cast of a negative float to `uint8` wraps rather than clamping, so the byte a demoted + /// player carries is the wrapped product. We carry the byte the wire sent; interpreting the + /// negative-rank case is the display edge's problem, not the decoder's. + /// + /// The field only exists from client 1.6.1 on (vmangos guards the write with + /// `SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`), which is live for build 5875. PRIVATE — + /// self only; `None` before the field streams. + pub fn player_honor_rank_bar(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_BYTES2) + .map(|b| (b & 0xff) as u8) + } /// `UNIT_FIELD_BYTES_0` byte 0 — the unit's race. `None` when the field wasn't sent. pub fn unit_race(&self) -> Option { self.unit_bytes_0().map(|b| (b & 0xff) as u8) diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index ea9b022f..a9443fc3 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -96,6 +96,40 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { FIELD_PLAYER_CHARACTER_POINTS2 + 1 ); assert_eq!(FIELD_PLAYER_TRACK_RESOURCES, UNIT_END + 0x395); + // The honor block (decision 1512) is eleven contiguous fields filling EXACTLY the gap between + // two already-anchored neighbours — the 12-slot buyback-timestamp array below it and the + // watched-faction slot above it — so it needs no anchor of its own. If any of the three ever + // drifts, one of these two closures breaks. + assert_eq!( + FIELD_PLAYER_BUYBACK_TIMESTAMP_1 + 12, + FIELD_PLAYER_FIELD_SESSION_KILLS, + "the honor block opens where the 12-slot buyback-timestamp array ends" + ); + assert_eq!( + FIELD_PLAYER_FIELD_BYTES2 + 1, + FIELD_PLAYER_WATCHED_FACTION_INDEX, + "and closes one below the watched-faction slot" + ); + assert_eq!(FIELD_PLAYER_FIELD_SESSION_KILLS, UNIT_END + 0x426); + assert_eq!(FIELD_PLAYER_FIELD_YESTERDAY_KILLS, UNIT_END + 0x427); + assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS, UNIT_END + 0x428); + assert_eq!(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS, UNIT_END + 0x429); + assert_eq!(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION, UNIT_END + 0x42A); + assert_eq!( + FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS, + UNIT_END + 0x42B + ); + assert_eq!( + FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS, + UNIT_END + 0x42C + ); + assert_eq!(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION, UNIT_END + 0x42D); + assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION, UNIT_END + 0x42E); + assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_RANK, UNIT_END + 0x42F); + assert_eq!(FIELD_PLAYER_FIELD_BYTES2, UNIT_END + 0x430); + // The CURRENT rank rides the appearance dword instead, nowhere near that block — which is the + // whole point: PLAYER_BYTES_3 is PUBLIC, so it is the one honor value a foreign player streams. + assert_eq!(FIELD_PLAYER_BYTES_3, UNIT_END + 0x7); } #[test] @@ -272,6 +306,103 @@ fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() { assert_eq!(ObjectFields::default().player_combo_points(), None); } +/// `PLAYER_BYTES_3` byte 3 is the **current** honor rank, and it shares its dword with the +/// gender+inebriation low u16 — so the fixture carries a non-zero drunk byte on purpose: a wrong +/// shift would read `0xA0` (160) as the rank and pass a byte-3-is-zero test. +/// +/// The pairing with `PLAYER_FIELD_BYTES`' byte 3 is the confusable one and the reason both are +/// asserted here: current rank (PUBLIC, field 195) vs highest lifetime rank (PRIVATE, field 1222). +/// A fixture carrying only 195 must answer `None` for the highest rank, not `0`. +#[test] +fn player_bytes_3_byte_3_is_the_current_pvp_rank_not_the_highest() { + // byte 0 = 0x01 gender, byte 1 = 0xA0 drunk, byte 2 = 0x04 city-protector title, + // byte 3 = 0x0B = internal rank 11 (visual 7). + let f = ObjectFields::from_pairs(&[(195, 0x0B_04_A0_01)]); + assert_eq!(f.player_pvp_rank(), Some(11)); + assert_eq!(f.player_drunk_byte(), Some(0xA0), "the neighbouring byte"); + assert_eq!( + f.player_honor_rank(), + None, + "the HIGHEST rank is a different field (1222) — absent means absent" + ); + // The two really do diverge: a demoted player keeps the higher lifetime rank. + let demoted = ObjectFields::from_pairs(&[(195, 0x03_00_00_00), (1222, 0x0B_00_00_00)]); + assert_eq!(demoted.player_pvp_rank(), Some(3)); + assert_eq!(demoted.player_honor_rank(), Some(11)); + assert_eq!(ObjectFields::default().player_pvp_rank(), None); +} + +/// The four TWO_SHORT kill counters split low = honorable / high = dishonorable +/// (`MAKE_PAIR32`; vmangos writes SESSION_KILLS as two `SetUInt16Value` halves). The other three +/// come off vmangos as a whole dword, so their high half is 0 — asserted, because "DK reads 0" is +/// a property of *this server*, not of the field, and a future capture that shows otherwise is a +/// finding rather than a regression. +#[test] +fn honor_kill_counters_split_into_honorable_and_dishonorable_halves() { + let f = ObjectFields::from_pairs(&[ + (1250, 0x0003_0011), // SESSION: 17 HK, 3 DK — both halves live + (1251, 0x0000_0029), // YESTERDAY: 41 HK, whole-dword write ⇒ DK half 0 + (1252, 0x0000_01A4), // LAST_WEEK: 420 HK + (1253, 0x0000_007B), // THIS_WEEK: 123 HK + ]); + assert_eq!(f.player_session_kills(), Some((17, 3))); + assert_eq!(f.player_yesterday_kills(), Some((41, 0))); + assert_eq!(f.player_last_week_kills(), Some((420, 0))); + assert_eq!(f.player_this_week_kills(), Some((123, 0))); + // A value that fills the high half of one of the dword-written fields still decodes as a + // pair — we carry the descriptor's shape, not vmangos's habit. + let both = ObjectFields::from_pairs(&[(1251, 0x0007_0029)]); + assert_eq!(both.player_yesterday_kills(), Some((41, 7))); + // Absent stays absent for every one of the four. + let empty = ObjectFields::default(); + assert_eq!(empty.player_session_kills(), None); + assert_eq!(empty.player_yesterday_kills(), None); + assert_eq!(empty.player_last_week_kills(), None); + assert_eq!(empty.player_this_week_kills(), None); +} + +/// The INT half of the honor block — three contribution totals, the weekly standing, and the two +/// lifetime counters — each reading its own index (a swapped constant shows up here). +#[test] +fn honor_contributions_standing_and_lifetime_read_their_own_fields() { + let f = ObjectFields::from_pairs(&[ + (1254, 1_250), // THIS_WEEK_CONTRIBUTION + (1255, 3_907), // LIFETIME_HONORABLE_KILLS + (1256, 12), // LIFETIME_DISHONORABLE_KILLS + (1257, 640), // YESTERDAY_CONTRIBUTION + (1258, 8_431), // LAST_WEEK_CONTRIBUTION + (1259, 57), // LAST_WEEK_RANK — the STANDING, not an honor rank + ]); + assert_eq!(f.player_this_week_contribution(), Some(1_250)); + assert_eq!(f.player_lifetime_honorable_kills(), Some(3_907)); + assert_eq!(f.player_lifetime_dishonorable_kills(), Some(12)); + assert_eq!(f.player_yesterday_contribution(), Some(640)); + assert_eq!(f.player_last_week_contribution(), Some(8_431)); + assert_eq!(f.player_last_week_rank(), Some(57)); + let empty = ObjectFields::default(); + assert_eq!(empty.player_this_week_contribution(), None); + assert_eq!(empty.player_yesterday_contribution(), None); + assert_eq!(empty.player_last_week_contribution(), None); + assert_eq!(empty.player_last_week_rank(), None); + assert_eq!(empty.player_lifetime_honorable_kills(), None); + assert_eq!(empty.player_lifetime_dishonorable_kills(), None); +} + +/// `PLAYER_FIELD_BYTES2` byte 0 is the rank progress bar; bytes 1..3 are a flags byte and two +/// unknowns, so the fixture sets them to catch a wrong shift. +#[test] +fn player_honor_rank_bar_is_byte_zero_of_player_field_bytes2() { + let f = ObjectFields::from_pairs(&[(1260, 0xDE_AD_BE_7F)]); + assert_eq!(f.player_honor_rank_bar(), Some(0x7F)); + // A negative rank's `uint8(fraction * -255)` wraps rather than clamping — we carry the byte + // the wire sent and leave the interpretation to the display edge. + assert_eq!( + ObjectFields::from_pairs(&[(1260, 0x0000_009C)]).player_honor_rank_bar(), + Some(0x9C) + ); + assert_eq!(ObjectFields::default().player_honor_rank_bar(), None); +} + /// `PLAYER_FIELD_COMBO_TARGET` reads as one GUID out of its two dwords — the unit the points are /// banked on, which the display gate compares against the current target (decision 0875). #[test] diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index 960c2fe2..913f2532 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -15,6 +15,7 @@ //! | [`self_movement`] | our own mover: the `MSG_MOVE_*` stream + every mover ack | //! | [`lifecycle`] | being logged in: ping, logout, the cinematic ack | //! | [`selection`] | the two sends that set our target: select, inspect | +//! | [`pvp`] | the honor system's one ask: another player's honor stats | //! | [`names`] | the ask-once name lookups: player, creature, pet | //! | [`chat`] | the chat frame's wire: every `MESSAGECHAT` flavour + `/played`, `/random`, `/wave` | //! | [`channel`] | channel administration: join/leave/list/moderation | @@ -73,6 +74,7 @@ mod pet; mod player_flags; mod pose; mod progression; +mod pvp; mod quest; mod reputation; mod selection; diff --git a/crates/benilla-protocol/src/world/writer/pvp.rs b/crates/benilla-protocol/src/world/writer/pvp.rs new file mode 100644 index 00000000..9cfeb34f --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/pvp.rs @@ -0,0 +1,33 @@ +//! The one honor-system send: asking for another player's honor stats (decision 1512). The +//! family's other message, `SMSG_PVP_CREDIT`, is inbound only. +//! +//! **Why its own module rather than [`super::selection`]** — that module is "the two sends that +//! set our selection", and this send does not: `HandleInspectOpcode` opens with +//! `SetSelectionGuid` (vmangos `MiscHandler.cpp:944`) while `HandleInspectHonorStatsOpcode` does +//! not (`MiscHandler.cpp:962-972`), so filing it there would make that module's own doc false. It +//! also follows `writer/mod.rs`'s stated, mechanical split rule: a send lives in the module named +//! after the [`crate::messages`] module its body builder lives in, and this body builder is +//! `messages::pvp::inspect_honor_stats`. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Ask for a player's honor stats (`MSG_INSPECT_HONOR_STATS`, a raw 8-byte guid — body and + /// server gates documented on [`messages::inspect_honor_stats`]). The reply rides the **same** + /// opcode and surfaces as [`crate::events::SessionEvent::InspectHonorStats`]. + /// + /// There is no failure reply: each of the server's three refusals (target offline, beyond + /// `INSPECT_DISTANCE`, a valid attack target) is a silent `return`. A caller must therefore + /// treat the ask as fire-and-forget and keep whatever it already had on screen, exactly as the + /// reference pane does — it re-asks on show and simply shows nothing until an answer lands. + pub fn inspect_honor_stats(&mut self, guid: u64) -> Result<()> { + self.send( + opcode::MSG_INSPECT_HONOR_STATS, + &messages::inspect_honor_stats(guid), + ) + } +} diff --git a/crates/benilla-protocol/tests/pvp.rs b/crates/benilla-protocol/tests/pvp.rs new file mode 100644 index 00000000..00f69873 --- /dev/null +++ b/crates/benilla-protocol/tests/pvp.rs @@ -0,0 +1,131 @@ +//! The honor-system wire route end to end (decision 1512), mirroring `src/messages/pvp.rs`: raw +//! body → [`messages::parse_server`] → `ServerPacket` → [`decode`] → `SessionEvent`. The +//! byte-exact field goldens live inline in `pvp.rs`; what this file adds is the part those cannot +//! reach — that the opcode actually dispatches to the right reader, that the packet names itself +//! correctly in the log, and that the event fan-out carries the payload through unchanged. See +//! `tests/common` for the shared fixtures and methodology note. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::ServerPacket; +use common::hx; + +/// The 50-byte `MSG_INSPECT_HONOR_STATS` reply, in `InspectHonorStatsResponse::AppendBodyTo` +/// order: guid `0x0000000100002AB3`, highestRank 11, sessionKills 17|2<<16, yesterdayHK 41, +/// unknownOld1 0, lastWeekHK 420, unknownOld2 0, thisWeekHK 123, unknownOld3 0, lifetimeHK 3907, +/// lifetimeDHK 12, yesterdayHonor 640, lastWeekHonor 8431, thisWeekHonor 1250, lastWeekRank 57, +/// rankBar 191. +fn inspect_honor_body() -> Vec { + hx(concat!( + "b32a000001000000", // u64 playerGuid + "0b", // u8 highestRank + "11000200", // u32 sessionKills (17 HK | 2 DK) + "2900", // u16 yesterdayHK + "0000", // u16 unknownOld1 + "a401", // u16 lastWeekHK + "0000", // u16 unknownOld2 + "7b00", // u16 thisWeekHK + "0000", // u16 unknownOld3 + "430f0000", // u32 lifetimeHK + "0c000000", // u32 lifetimeDHK + "80020000", // u32 yesterdayHonor + "ef200000", // u32 lastWeekHonor + "e2040000", // u32 thisWeekHonor + "39000000", // u32 lastWeekRank (the standing) + "bf", // u8 rankBar + )) +} + +/// The reply dispatches off opcode 0x2D6 and reaches the UI as one event with every field intact. +/// The opcode is an `MSG_` — the same number carries our request — but `parse_server` only ever +/// sees inbound bodies, so there is nothing to disambiguate here. +#[test] +fn inspect_honor_stats_wire() { + let body = inspect_honor_body(); + assert_eq!(body.len(), 50, "the 1.12.1 reply body is exactly 50 bytes"); + let packet = messages::parse_server(messages::opcode::MSG_INSPECT_HONOR_STATS, &body).unwrap(); + assert_eq!(packet.name(), "MSG_INSPECT_HONOR_STATS"); + let ServerPacket::InspectHonorStats(stats) = packet else { + panic!("inspect honor stats"); + }; + assert_eq!(stats.player_guid, 0x0000_0001_0000_2AB3); + assert_eq!(stats.highest_rank, 11); + assert_eq!(stats.session_kills(), (17, 2)); + assert_eq!( + (stats.yesterday_hk, stats.last_week_hk, stats.this_week_hk), + (41, 420, 123) + ); + assert_eq!( + (stats.unknown_old1, stats.unknown_old2, stats.unknown_old3), + (0, 0, 0), + "vmangos writes all three deprecated slots as 0" + ); + assert_eq!((stats.lifetime_hk, stats.lifetime_dhk), (3_907, 12)); + assert_eq!( + ( + stats.yesterday_honor, + stats.last_week_honor, + stats.this_week_honor + ), + (640, 8_431, 1_250) + ); + assert_eq!(stats.last_week_rank, 57, "the STANDING, not a rank"); + assert_eq!(stats.rank_bar, 191); + + match decode(ServerPacket::InspectHonorStats(stats)).as_slice() { + [SessionEvent::InspectHonorStats(ev)] => assert_eq!(*ev, stats), + other => panic!("one inspect-honor event, got {other:?}"), + } + + // Our own request on the same opcode is the bare guid — a different shape entirely, which is + // exactly why direction and not content has to tell the two apart. + assert_eq!( + messages::inspect_honor_stats(0x0000_0001_0000_2AB3), + hx("b32a000001000000") + ); +} + +/// `SMSG_PVP_CREDIT` dispatches off 0x28C and carries honor / victim / rank through to the event. +/// The victim rank is the **internal** rank (`SendPVPCredit` sends `GetRank().rank`, floored at 5 +/// for a player victim) — a direct `PVP_RANK__` key, not the visual badge number. +#[test] +fn pvp_credit_wire() { + // i32 honor = 143, u64 victimGuid = 0x0000000100002AB3, i32 victimRank = 11. + let body = hx("8f000000b32a0000010000000b000000"); + assert_eq!(body.len(), 16); + let packet = messages::parse_server(messages::opcode::SMSG_PVP_CREDIT, &body).unwrap(); + assert_eq!(packet.name(), "SMSG_PVP_CREDIT"); + let ServerPacket::PvpCredit(credit) = packet else { + panic!("pvp credit"); + }; + assert_eq!(credit.honor, 143); + assert_eq!(credit.victim_guid, 0x0000_0001_0000_2AB3); + assert_eq!(credit.victim_rank, 11); + + match decode(ServerPacket::PvpCredit(credit)).as_slice() { + [SessionEvent::PvpCredit(ev)] => assert_eq!(*ev, credit), + other => panic!("one pvp-credit event, got {other:?}"), + } + + // A dishonorable kill rides the same packet with negative honor (`HonorMgr.cpp:807`). + let dk = hx("fbffffffb32a0000010000000b000000"); + let ServerPacket::PvpCredit(credit) = + messages::parse_server(messages::opcode::SMSG_PVP_CREDIT, &dk).unwrap() + else { + panic!("pvp credit"); + }; + assert_eq!(credit.honor, -5); +} + +/// A truncated body must surface as a parse error, not a panic — both opcodes, at the wire edge +/// rather than only at the reader. +#[test] +fn truncated_honor_bodies_error_at_the_dispatch() { + let full = inspect_honor_body(); + assert!( + messages::parse_server(messages::opcode::MSG_INSPECT_HONOR_STATS, &full[..49]).is_err() + ); + assert!(messages::parse_server(messages::opcode::SMSG_PVP_CREDIT, &[0u8; 15]).is_err()); +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 95e3277a..bde30363 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -174,6 +174,7 @@ pub(crate) use model::Model; pub use model::TextureProbe; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo}; pub use pet::{PetActionView, PetStats}; +pub use pvp::{HonorState, InspectHonorData}; pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 932a66b4..38f12678 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -5,7 +5,7 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena}; use super::{ backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, guild, - inspect, item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, + inspect, item_text, loot, loot_roll, macros, mail, merchant, party, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState, @@ -453,6 +453,27 @@ pub(crate) struct Model { /// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a /// payload: `CMSG_TOGGLE_PVP` carries no body. pub(crate) pvp_toggles: u32, + /// The local player's honor snapshot ([`pvp::HonorState`], decision 1512) — the PRIVATE honor + /// descriptor fields the app decodes and pushes ([`super::UiScript::set_honor`]). `None` + /// before the first push, which the six self getters read as a zeroed snapshot (see + /// [`pvp`]'s module doc for why they never return short). + pub(crate) honor: Option, + /// The last `MSG_INSPECT_HONOR_STATS` reply the app pushed ([`pvp::InspectHonorData`]). Its + /// PRESENCE is `HasInspectHonorData`'s answer — the latch the reference's inspect pane gates + /// its request on — so `None` is a state, not a gap. + pub(crate) inspect_honor: Option, + /// `RequestInspectHonorData` calls since the app's last + /// [`super::UiScript::take_inspect_honor_requests`] drain. A count for [`Self::pvp_toggles`]'s + /// reason: the binding takes no argument, and the guid the send needs is the app's own inspect + /// target. With [`Self::inspect_honor_pending`] gating the binding it can only ever drain `0` + /// or `1` — the engine has at most one query outstanding. + pub(crate) inspect_honor_requests: u32, + /// The engine's `pending` flag (`0xb71fcc`): a `MSG_INSPECT_HONOR_STATS` query is queued and + /// no reply has landed. `RequestInspectHonorData` (`0x4c80a0`) refuses while it is set, which + /// is what stops a pane shown/hidden/shown in quick succession sending duplicate queries. + /// Cleared by **either** arm of [`super::UiScript::set_inspect_honor`] — a reply landed + /// (`0x4c6f4c`), or the app invalidated the slot (`0x4c6f9d`). + pub(crate) inspect_honor_pending: bool, /// The two **equipment-display** preferences as the VM currently believes them — /// `ShowingHelm()` / `ShowingCloak()` ([`worn_display`], decision 1472). Not a setting: the /// truth is `PLAYER_FLAGS`' `HIDE_HELM`/`HIDE_CLOAK` bits, pushed here on the descriptor edge @@ -1311,6 +1332,10 @@ impl Model { follow_requests: Vec::new(), session_requests: Vec::new(), pvp_toggles: 0, + honor: None, + inspect_honor: None, + inspect_honor_requests: 0, + inspect_honor_pending: false, helm_shown: true, cloak_shown: true, worn_display_toggles: Vec::new(), diff --git a/crates/benilla-ui/src/script/pvp.rs b/crates/benilla-ui/src/script/pvp.rs deleted file mode 100644 index 18a8162d..00000000 --- a/crates/benilla-ui/src/script/pvp.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The PvP-flag **Era API surface** (decision 0646) — one global, no state. -//! -//! `TogglePVP()` is the whole outbound half of the PvP family: everything the UI *reads* is unit -//! state that already arrives on the [`super::unit`] snapshot (`UnitIsPVP`, `UnitIsPVPFreeForAll`, -//! `UnitFactionGroup`). The call queues a toggle the app drains -//! ([`super::UiScript::take_pvp_toggles`]) and turns into its `CMSG_TOGGLE_PVP` send, keeping the -//! engine free of ECS/net reach (decision 0068 §3) — [`super::duel`]'s shape, one verb smaller. -//! -//! The reference registers the binding at `0x48d700` and calls it from exactly one place in the -//! whole shipped 1.12 UI: `SlashCmdList["PVP"]` (ChatFrame.lua). benilla's popup row is a -//! deliberate second caller — decision 0646 §3. - -use mlua::Lua; - -use super::Model; - -impl super::UiScript { - /// Drain the PvP-flag toggles queued since the last call — each one is a `CMSG_TOGGLE_PVP`. - /// A count rather than a payload: the packet is empty, so two toggles in a frame are two - /// sends, not one collapsed intent. - pub fn take_pvp_toggles(&mut self) -> u32 { - std::mem::take(&mut self.model_mut().pvp_toggles) - } - - /// Queue a toggle from the app side — the `/pvp` slash command. In the reference that slash - /// handler *is* Lua (a one-liner over `TogglePVP`); benilla parses slash lines in Rust, so the - /// same intent enters the same queue here rather than through the global ([`super::duel`]'s - /// `queue_duel_request` reasoning, verbatim). - pub fn queue_pvp_toggle(&mut self) { - self.model_mut().pvp_toggles += 1; - } -} - -/// Register the PvP globals. -pub(super) fn install(lua: &Lua) -> mlua::Result<()> { - // TogglePVP() — /pvp and the unit popup's PvP row. Takes no argument in 1.12: the *state* - // form of the opcode (a one-byte body) has no binding, so there is nothing to pass. - lua.globals().set( - "TogglePVP", - lua.create_function(|lua, ()| { - let mut model = lua.app_data_mut::().expect("model app_data"); - model.pvp_toggles += 1; - Ok(()) - })?, - ) -} diff --git a/crates/benilla-ui/src/script/pvp/mod.rs b/crates/benilla-ui/src/script/pvp/mod.rs new file mode 100644 index 00000000..9b1ba805 --- /dev/null +++ b/crates/benilla-ui/src/script/pvp/mod.rs @@ -0,0 +1,835 @@ +//! The **PvP + honor** surface — `TogglePVP` (decision 0646) and the vanilla honor system's +//! thirteen bindings (decision 1512): the character window's Honor tab, the inspect window's Honor +//! tab, and the rank every unit frame can read off a *foreign* player. +//! +//! ## The three seams +//! +//! The crate's usual split (decision 0068 §3 — no ECS, no wire, no DBC in here): +//! +//! - **Pushed state.** The local player's honor counters are PRIVATE descriptor fields +//! (`PLAYER_FIELD_SESSION_KILLS` … `PLAYER_FIELD_BYTES2`), so the app decodes them and pushes a +//! [`HonorState`] ([`UiScript::set_honor`]); the six self getters read it and nothing else. +//! The inspect pane's numbers are **not** a descriptor: `MSG_INSPECT_HONOR_STATS` (`0x2D6`) is a +//! request/reply pair, so they arrive separately as an [`InspectHonorData`] +//! ([`UiScript::set_inspect_honor`]) — and *whether one is held* is exactly what +//! `HasInspectHonorData` reports, which is the latch the reference's `OnShow` gates its request +//! on. That is why the inspect half is an `Option`, and the self half too — but the `Option` is +//! the **latch only**: the engine's data slots are zero-initialised BSS that every getter reads +//! ungated, so "nothing held" answers **zeros at full width**, never nils and never a short +//! return. The one thing an empty slot changes is `HasInspectHonorData`. +//! - **Intents.** `TogglePVP` queues a `CMSG_TOGGLE_PVP` ([`UiScript::take_pvp_toggles`]) and +//! `RequestInspectHonorData` queues the honor query +//! ([`UiScript::take_inspect_honor_requests`]) — both counts rather than payloads, because +//! neither packet carries anything the VM knows: the toggle has an empty body, and the honor +//! query's body is the *inspected player's* guid, which the app already holds +//! ([`super::inspect`]'s target). Same reasoning as `NotifyInspect`, one argument smaller. +//! The honor query carries a second piece of engine state with it — the **`pending` latch** +//! (`0xb71fcc`), which refuses a second query while one is in flight; see the binding. +//! - **The unit snapshot.** A *foreign* player's rank is knowable at all because `PLAYER_BYTES_3` +//! is a PUBLIC field — byte 3 of it is the CURRENT rank — so it rides +//! [`UnitState::pvp_rank`](super::UnitState::pvp_rank) with the other public per-unit facts and +//! `UnitPVPRank("target")` answers for the inspect pane exactly as the reference's does. +//! +//! ## Two ranks, and neither is the other +//! +//! - **Internal rank**, `0..=19`: the byte on the wire, and the number `GetPVPRankInfo` keys its +//! title lookup on. `0` is "no rank"; `1..=4` are the dishonorable ranks; `5..=18` are +//! Scout/Private through High Warlord/Grand Marshal; `19` is the racial-leader "Leader", which +//! the server sends in `SMSG_PVP_CREDIT` and **`GetPVPRankInfo` refuses** (see below). +//! - **Visual rank**, `-4..=14`: `rank >= 5 ? rank - 4 : rank - 5` (`0x51aa31`) — what +//! `GetPVPRankInfo` *returns* as its second value, and what FrameXML indexes the badge texture by +//! (`"…PvPRank"..format("%02d", rankNumber)`, drawn only when it is positive). +//! +//! **The negative half runs the other way from the server's.** vmangos's `HonorMgr.cpp:991` +//! computes `visualRank = rank > 4 ? rank - 4 : rank * -1`, which gives rank 1 → −1 and rank 4 → +//! −4; the *binding* subtracts 5 instead of negating, so it gives rank 1 → **−4** and rank 4 → +//! **−1**. They genuinely disagree, and the client is the authority for what the client's binding +//! returns — [`visual_rank`] implements the binding. Nothing here reads the server's form, and a +//! reader "fixing" this back to `-rank` would be reverting a §5 cross-check. +//! +//! Conflating the two scales puts Sergeant's badge on a Corporal, so [`visual_rank`] is the one +//! place the conversion happens and every caller goes through it. The trap is that both numbers +//! travel: the badge and `GetPVPRankInfo`'s second return are **visual**, while the wire — +//! `PLAYER_BYTES_3` byte 3, `GetInspectHonorData`'s `lifetimeRank`, and `SMSG_PVP_CREDIT`'s +//! `victimRank` (decision 1512 §2's second-pass correction: `HonorMgr::SendPVPCredit` sends +//! `GetRank().rank`) — is **internal** throughout, which is what lets a rank byte index a +//! `PVP_RANK_*` GlobalString with no conversion at all. +//! +//! ## Why the rank titles come out of the VM's own globals +//! +//! A rank's name is the GlobalString `PVP_RANK__` — the engine's one format string +//! (`"PVP_RANK_%d_%d"` @ `0x8445b4`), team `0` Horde and `1` Alliance (VERIFIED against the shipped +//! `FactionGroup.dbc` *and* `GlobalStrings.lua`: `PVP_RANK_5_0` is "Scout", `PVP_RANK_5_1` is +//! "Private"). The real `GlobalStrings.lua` is executed off the player's own install at boot, so +//! those keys are already sitting on `_G` when `GetPVPRankInfo` runs, and reading them there is the +//! only correct answer: a table of titles shipped in this repo would be Blizzard text we may not +//! carry (the contract's install-content rule) *and* wrong in every non-enUS locale. There is **no +//! `PVP_RANK_0_*`** — internal rank 0 names nothing, and both reference panes lean on that nil to +//! fall back to the `NONE` GlobalString, so a missing key answers nil here rather than an empty +//! string or a raise. +//! +//! The team digit is a **tri-state**, not a bool: `0x5efe00` answers `0` Horde, `1` Alliance, or +//! **`-1`** for a unit with no side, and −1 is not special-cased anywhere — it is formatted into +//! the key, `PVP_RANK_9_-1` matches no GlobalString, and the miss *is* the "no title" answer. +//! +//! ## `_FEMALE` is real, and the honor pane never sees it +//! +//! The `_FEMALE` twins ship in `GlobalStrings.lua`, and the engine cannot build their keys: the +//! byte sequence `FEMALE` does not occur anywhere in `WoW.exe`. It reaches them by asking +//! **FrameXML** — `0x703bf0(key, ordinal, gender)` takes a Lua path whenever `gender != 0`, calling +//! `GetText(token, gender, ordinal)`, which appends the suffix for gender 3. So the *suffix* is +//! Lua's and the *request* is the engine's, per call site: +//! +//! - **`GetPVPRankInfo` passes gender 0** (`0x51aa0f`) and takes the fast path — so the character +//! and inspect honor panes render the **male/default title even for a female character**. +//! - **`UnitPVPName`'s title is gendered** by the subject unit (`0x612bf0`), and +//! **`SMSG_PVP_CREDIT`'s is gendered by the LOCAL PLAYER** even though the *team* digit is the +//! victim's (`0x625374`) — the app's chat line owns that asymmetry, through +//! [`UiScript::pvp_rank_title`]. +//! +//! So [`rank_title_ungendered`] and [`rank_title_gendered`] are two functions on purpose, and +//! `GetPVPRankInfo` calls the first. **A client that gendered the pane's title would be more +//! "correct" than the reference and would diverge from it**, which is not what we build. +//! +//! ## The counter-intuitive four (wow-re `system/ui/scratch/honor-panel-law.md`, §5, 2026-08-21) +//! +//! Everything below is byte law from that carve, and every one of them looks like a bug: +//! +//! 1. `GetPVPRankProgress` **multiplies by a slightly-wrong f32 reciprocal** and does not clamp — +//! [`rank_progress`]. +//! 2. The four dishonorable ranks' visual numbers run **backwards** from the server's — see "Two +//! ranks" above. +//! 3. `GetPVPRankInfo` **rejects rank 19**, whose GlobalString exists and which the credit line and +//! `UnitPVPName` both render. +//! 4. `GetPVPLifetimeStats` **suppresses a highest-lifetime rank below 5**, reporting `0`. + +use mlua::{Lua, MultiValue, Value}; + +use super::binding_abi::{number_arg, string_arg}; +use super::unit::{check_unit_token, is_civilian_kill}; +use super::Model; + +/// The local player's honor snapshot — every number the character window's Honor tab shows, as the +/// app decoded it from the PRIVATE honor descriptor fields (decision 1512 §1). EXACT shape the app +/// feed is written against; do not rename. +/// +/// The kill counters are `u16` because their fields are `TWO_SHORT` descriptors read as halves +/// (`PLAYER_FIELD_SESSION_KILLS` low = honorable, high = dishonorable); the honor totals are the +/// whole-dword `*_CONTRIBUTION` fields. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HonorState { + /// `PLAYER_FIELD_SESSION_KILLS` low half — honorable kills this session. + pub session_hk: u16, + /// `PLAYER_FIELD_SESSION_KILLS` high half — dishonorable kills this session. + pub session_dk: u16, + /// `PLAYER_FIELD_YESTERDAY_KILLS` halves. + pub yesterday_hk: u16, + pub yesterday_dk: u16, + /// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` — yesterday's honor. + pub yesterday_honor: u32, + /// `PLAYER_FIELD_THIS_WEEK_KILLS` low half. There is no this-week DK getter: the reference + /// destructures exactly two values from `GetPVPThisWeekStats`. + pub this_week_hk: u16, + /// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION`. + pub this_week_honor: u32, + /// `PLAYER_FIELD_LAST_WEEK_KILLS` halves. + pub last_week_hk: u16, + pub last_week_dk: u16, + /// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION`. + pub last_week_honor: u32, + /// `PLAYER_FIELD_LAST_WEEK_RANK` — last week's **standing** (the ladder position), which is + /// `GetPVPLastWeekStats`'s fourth return and is not a rank at all. + pub last_week_standing: u32, + /// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…_DISHONORBALE_KILLS` (the server's spellings). + pub lifetime_hk: u32, + pub lifetime_dk: u32, + /// `PLAYER_FIELD_BYTES` byte 3 — the HIGHEST LIFETIME rank, internal scale. PRIVATE, and + /// already decoded elsewhere in the app as the honor-rank byte. + /// + /// **Carried raw.** `GetPVPLifetimeStats` suppresses a value below 5 to `0` (`0x51a843`), and + /// that suppression is the *binding's*, not the feed's — push the true byte and let the + /// binding hide it, because the same field read through any other door is not hidden. + pub highest_rank: u8, + /// `PLAYER_BYTES_3` byte 3 — the **current** rank, internal scale, `0` = no rank. The same + /// PUBLIC byte that reaches every observer as [`UnitState::pvp_rank`](super::UnitState). + pub rank: u8, + /// `PLAYER_FIELD_BYTES2` byte 0 — progress through the current rank, `0..=255`. Carried as the + /// raw byte: the fraction is [`rank_progress`]'s business, not the feed's. + pub rank_bar: u8, +} + +/// One `MSG_INSPECT_HONOR_STATS` reply (decision 1512 §2), as the app decoded its 50-byte body. +/// +/// Fourteen numbers and the guid they were about. **There is no per-period DK here** — the reply +/// carries a session HK/DK pair and then bare HK counts for yesterday/last week/this week (the +/// three `unknownOld` shorts between them are written as zero by the server), which is why +/// `GetInspectHonorData` returns twelve values while the self getters together return more. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct InspectHonorData { + /// The player guid the reply was for — the app's key for "is this still the player we are + /// looking at?". Never returned to Lua; the reference's pane is keyed by its own unit token. + pub guid: u64, + pub session_hk: u16, + pub session_dk: u16, + pub yesterday_hk: u16, + pub yesterday_honor: u32, + pub this_week_hk: u16, + pub this_week_honor: u32, + pub last_week_hk: u16, + pub last_week_honor: u32, + /// The ladder position, the reply's `lastWeekRank` — `GetInspectHonorData`'s ninth return. + pub last_week_standing: u32, + pub lifetime_hk: u32, + pub lifetime_dk: u32, + /// The reply's `highestRank` byte — `GetInspectHonorData`'s twelfth return, which the + /// reference names `lifetimeRank`. Internal scale, like every other rank here. + /// + /// **Passed through, where its self-side twin is suppressed below 5.** The carve pins + /// `GetPVPLifetimeStats`'s `cmp al,5; jb` at `0x51a843` and records `0x4c9620` as twelve plain + /// pushes with no such gate, so this one is not filtered here. That is a *recorded absence* + /// rather than an asserted negative: if a re-read of `0x4c9620` ever turns up the same + /// compare, this field's getter is the one place it belongs — and nothing else should be + /// harmonised to match it in the meantime. + pub highest_rank: u8, + /// The reply's trailing `rankBar` byte — read only by `GetInspectPVPRankProgress`. + pub rank_bar: u8, +} + +impl super::UiScript { + /// Drain the PvP-flag toggles queued since the last call — each one is a `CMSG_TOGGLE_PVP`. + /// A count rather than a payload: the packet is empty, so two toggles in a frame are two + /// sends, not one collapsed intent. + pub fn take_pvp_toggles(&mut self) -> u32 { + std::mem::take(&mut self.model_mut().pvp_toggles) + } + + /// Queue a toggle from the app side — the `/pvp` slash command. In the reference that slash + /// handler *is* Lua (a one-liner over `TogglePVP`); benilla parses slash lines in Rust, so the + /// same intent enters the same queue here rather than through the global ([`super::duel`]'s + /// `queue_duel_request` reasoning, verbatim). + pub fn queue_pvp_toggle(&mut self) { + self.model_mut().pvp_toggles += 1; + } + + /// Push (or clear, with `None`) the local player's honor snapshot — the six self getters' + /// only source. Pushed on the honor descriptor edge, not per frame; it fires no event of its + /// own, because the events the reference's pane listens for (`PLAYER_PVP_KILLS_CHANGED`, + /// `PLAYER_PVP_RANK_CHANGED`) are the app's to fire alongside the push. + pub fn set_honor(&mut self, honor: Option) { + self.model_mut().honor = honor; + } + + /// Push (or clear, with `None`) the last `MSG_INSPECT_HONOR_STATS` reply. + /// + /// **The presence of this is `HasInspectHonorData`'s answer**, so clearing it is as + /// load-bearing as pushing it: the app clears when it drops the inspected player + /// (`ClearInspectPlayer`), which is what makes the reference's `OnShow` re-request for the + /// next one instead of painting the previous player's numbers. Pair it with the pane's + /// `INSPECT_HONOR_UPDATE` event. + /// + /// **Both arms clear the in-flight latch**, exactly as the engine's two writers do: a reply + /// landing zeroes `pending` at `0x4c6f4c`, and re-keying or clearing the slot zeroes it at + /// `0x4c6f9d`. So an invalidation also says "whatever was in flight is no longer wanted", + /// which is what lets the *next* `RequestInspectHonorData` through. + pub fn set_inspect_honor(&mut self, data: Option) { + let mut model = self.model_mut(); + model.inspect_honor = data; + model.inspect_honor_pending = false; + } + + /// Drain the `RequestInspectHonorData` calls queued since the last call — each one is a + /// `MSG_INSPECT_HONOR_STATS` send carrying the app's current inspect target guid. + /// + /// A count, for [`Self::take_pvp_toggles`]'s reason: the binding takes no argument, so there + /// is no payload to carry, and the request the *app* addresses is its own inspect target. It + /// drains **0 or 1** and never more, because the binding's `pending` latch (`0x4c80b6`) + /// refuses a second query until [`Self::set_inspect_honor`] resolves the first — a count kept + /// rather than a `bool` because that latch is the engine's, not this signature's, and the app + /// loop over it is identical either way. + pub fn take_inspect_honor_requests(&mut self) -> u32 { + std::mem::take(&mut self.model_mut().inspect_honor_requests) + } + + /// A PvP rank's localized title for the app's `SMSG_PVP_CREDIT` chat line — the + /// `PVP_RANK__` GlobalString off the VM's own globals (`team` `0` Horde, `1` + /// Alliance), **gendered**: the `_FEMALE` twin when `female`, falling back to the base key. + /// A missing or empty key is `None`, never an empty string. + /// + /// **Gendered is right *here* and wrong in the pane**, which is the whole reason this is not + /// simply `GetPVPRankInfo`'s lookup: the credit formatter resolves through `0x612bf0` + /// (§4.3-VERIFIED) while `GetPVPRankInfo` passes gender 0. It shares [`rank_title_gendered`] + /// with `UnitPVPName`, the engine's other gendered caller. + /// + /// The number `SMSG_PVP_CREDIT` carries is the **INTERNAL** rank — the GlobalString index + /// itself, which is precisely why it can be passed straight in — and NOT the visual rank the + /// badge indexes; see the module doc's "Two ranks". Unlike the pane's binding this path applies + /// **no range check**, so rank 19 legitimately names "Leader" here. + /// + /// Two facts the caller owns, both from the same carve, because neither is expressible in this + /// signature: the gender is the **local player's** while the team is the **victim's** + /// (`0x625374` hands `0x612bf0` the entry `this`, still the local player), and a victim whose + /// team resolves to **−1 emits no line at all** (`0x625321 js` bails the whole formatter) — + /// which is a `u8` here precisely so that decision cannot be smuggled into a key that misses. + pub fn pvp_rank_title(&self, rank: u8, team: u8, female: bool) -> Option { + rank_title_gendered(self.lua(), i64::from(rank), i64::from(team), female) + } +} + +/// Internal rank → **visual** rank, `0x51aa31`'s two-legged subtraction: +/// +/// ```text +/// 51aa31 cmp edi,0x5 +/// 51aa36 jge 0x51aa55 ; SIGNED +/// 51aa38 edi += -5 ; ranks 1..4 -> -4 .. -1 +/// 51aa55 edi += -4 ; ranks 5..18 -> 1 .. 14 +/// ``` +/// +/// Defined on `1..=18` only, because that is the whole domain that reaches it: `GetPVPRankInfo` +/// range-gates first and answers `0` for everything outside, so there is no "rank 0 maps to 0" case +/// to write here — the badge stays off an unranked player through the gate, not through this. +/// +/// The negative leg **subtracts 5**; it does not negate. See the module doc — the server's +/// `visualRank` negates and the two therefore disagree for exactly ranks 1..4. +fn visual_rank(rank: i64) -> i64 { + if rank >= 5 { + rank - 4 + } else { + rank - 5 + } +} + +/// The f32 constant at `0x8026c8` — the four bytes `81 80 80 3b` — that `GetPVPRankProgress` and +/// `GetInspectPVPRankProgress` multiply the rank-bar byte by. It is the f32 *nearest* `1/255` +/// (`0.003921568859368563`, against `1/255`'s `0.00392156862745098`) and it is **not** `1/255`. +const RANK_BAR_SCALE: f64 = f32::from_bits(0x3B80_8081) as f64; + +/// The rank progress bar's byte → the fraction the reference feeds straight to a `StatusBar` whose +/// `minValue`/`maxValue` are `0`/`1`. +/// +/// **A multiply by [`RANK_BAR_SCALE`], not a division by 255, and no clamp** — both halves are the +/// §5 verdict correcting the natural reading, and both are deliberate: +/// +/// - `0x51aace` is `fild DWORD` then `fmul DWORD PTR ds:0x8026c8`. Under the client's PC_53 x87 +/// invariant `(double)b * (double)K` and `(double)b / 255.0` differ for **255 of the 256** byte +/// values (all but 0), so `bar as f64 / 255.0` is bit-wrong on every non-zero input. A byte of +/// 255 comes out as `1.0000000091389835`, *above* 1.0, and a byte of 51 is not exactly 0.2. +/// - There is no `fcom`, no min/max and no compare anywhere in either function. The value is +/// `byte * K` and nothing else. Clamping would be tidier and would be a divergence — and it +/// would hide precisely the >1.0 overshoot that proves the multiply. +/// +/// A full bar therefore hands `StatusBar:SetValue` a number a hair over its `maxValue`. That is +/// what the real client does; how our `StatusBar` treats an out-of-range value is that widget's +/// business, not this binding's. +fn rank_progress(bar: u8) -> f64 { + f64::from(bar) * RANK_BAR_SCALE +} + +/// The local player's honor snapshot, or `None` before the first push. +fn honor(lua: &Lua) -> Option { + lua.app_data_ref::().expect("model app_data").honor +} + +/// The last inspect-honor reply, or `None` when none is held. +fn inspect_honor(lua: &Lua) -> Option { + lua.app_data_ref::() + .expect("model app_data") + .inspect_honor +} + +/// A GlobalString off the VM's own globals, `None` when it is absent **or empty** — the +/// no-install / bare-VM path. (`GetPVPRankInfo` tests both explicitly: `0x51aa1c` NULL, +/// `0x51aa20` empty first byte.) +fn global_string(lua: &Lua, key: &str) -> Option { + lua.globals() + .get::>(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 { + // `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::().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::().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| 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__`, **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 { + 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 { + 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::().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::().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"), 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::().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::().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; diff --git a/crates/benilla-ui/src/script/pvp/tests.rs b/crates/benilla-ui/src/script/pvp/tests.rs new file mode 100644 index 00000000..b96e484e --- /dev/null +++ b/crates/benilla-ui/src/script/pvp/tests.rs @@ -0,0 +1,1010 @@ +//! The PvP + honor binding tests (the parent module is the unit under test). +//! +//! Several of these pin facts that look like bugs — the multiply-not-divide rank bar, the +//! backwards negative visual ranks, rank 19's rejection, the ungendered pane title, the suppressed +//! sub-5 lifetime rank. They are wow-re `system/ui/scratch/honor-panel-law.md` (§5, 2026-08-21) +//! and each one would pass just as happily against the wrong reading if it were written loosely, +//! so they are written tightly on purpose. + +use super::*; +use crate::script::{UiScript, UnitState}; + +/// A snapshot whose every number is distinct, so a swapped return order cannot pass: the tens +/// digit is the block (session 1x, yesterday 2x, this week 3x, last week 4x, lifetime 5x) and +/// the units digit is the position within it. +fn honor_state() -> HonorState { + HonorState { + session_hk: 11, + session_dk: 12, + yesterday_hk: 21, + yesterday_dk: 22, + yesterday_honor: 23, + this_week_hk: 31, + this_week_honor: 32, + last_week_hk: 41, + last_week_dk: 42, + last_week_honor: 43, + last_week_standing: 44, + lifetime_hk: 51, + lifetime_dk: 52, + highest_rank: 11, + rank: 9, + // 51 is 0.2 of 255 exactly — which the engine's multiply does NOT produce, and that + // near-miss is the whole point of the byte chosen here. + rank_bar: 51, + } +} + +/// The inspect reply's twelve, same trick: 6x is the session block, then one number per return +/// so the ninth (the standing) cannot hide behind the eighth. +fn inspect_state() -> InspectHonorData { + InspectHonorData { + guid: 0xDEAD_BEEF, + session_hk: 61, + session_dk: 62, + yesterday_hk: 63, + yesterday_honor: 64, + this_week_hk: 65, + this_week_honor: 66, + last_week_hk: 67, + last_week_honor: 68, + last_week_standing: 69, + lifetime_hk: 70, + lifetime_dk: 71, + highest_rank: 12, + rank_bar: 255, + } +} + +/// A female Alliance player carrying rank 9 (visual 5) — the snapshot `UnitPVPRank("player")` +/// and every title lookup read. +fn player() -> UnitState { + UnitState { + exists: true, + name: Some("Benilla".into()), + sex: 3, // female + is_player: true, + faction_group: Some("Alliance".into()), + pvp_rank: 9, + ..Default::default() + } +} + +fn seated() -> UiScript { + let mut s = UiScript::new().expect("VM"); + s.set_honor(Some(honor_state())); + s.set_unit("player", Some(player())); + s +} + +/// Install the rank titles the real `GlobalStrings.lua` puts on `_G` at boot — the only place +/// `GetPVPRankInfo` reads names from (see the module doc). Both teams, one female twin, the +/// dishonorable end and rank 19, so the key construction is pinned rather than assumed. +/// +/// Rank **17** is deliberately left unseated: it is the "the key exists in no locale" case the nil +/// tests need, now that 19 is a real title here. +fn seat_rank_globals(s: &UiScript) { + let g = s.lua().globals(); + for (key, value) in [ + ("PVP_RANK_1_1", "Pariah"), + ("PVP_RANK_4_1", "Dishonored"), + ("PVP_RANK_5_0", "Scout"), + ("PVP_RANK_5_1", "Private"), + ("PVP_RANK_9_0", "Senior Sergeant"), + ("PVP_RANK_9_1", "Sergeant Major"), + ("PVP_RANK_9_1_FEMALE", "Sergeant Major (f)"), + ("PVP_RANK_18_1", "Grand Marshal"), + // The racial-leader rank: a real GlobalString the pane's binding still refuses. + ("PVP_RANK_19_1", "Leader"), + ("PVP_RANK_19_1_FEMALE", "Leader (f)"), + ] { + g.set(key, value).expect("global string"); + } +} + +/// The GlobalStrings `UnitPVPName`'s decoration reads — the enUS templates, which is what makes +/// the two-`%s` substitution testable at all (see [`format_two_strings`]). +fn seat_name_globals(s: &UiScript) { + let g = s.lua().globals(); + for (key, value) in [ + ("UNIT_PVP_NAME", "%s %s"), + ("PVP_RANK_CIVILIAN", "Civilian"), + ("PVP_MEDAL1", "Guardian of Stormwind"), + ] { + g.set(key, value).expect("global string"); + } +} + +/// **The reference's own destructuring**, line for line — the highest-value assertion +/// available, because it is the exact code `HonorFrame.lua` runs. Four from last week, three +/// from yesterday, two from this week: the arities are not symmetric and getting one wrong +/// shifts a whole pane by a column. +#[test] +fn the_reference_destructuring_lands_every_value_in_the_right_slot() { + let s = seated(); + + assert_eq!( + s.eval::<(i64, i64)>("local hk, dk = GetPVPSessionStats() return hk, dk") + .unwrap(), + (11, 12) + ); + assert_eq!( + s.eval::<(i64, i64, i64)>( + "local hk, dk, contribution = GetPVPYesterdayStats() return hk, dk, contribution" + ) + .unwrap(), + (21, 22, 23) + ); + assert_eq!( + s.eval::<(i64, i64)>( + "local hk, contribution = GetPVPThisWeekStats() return hk, contribution" + ) + .unwrap(), + (31, 32) + ); + assert_eq!( + s.eval::<(i64, i64, i64, i64)>( + "local hk, dk, contribution, rank = GetPVPLastWeekStats() \ + return hk, dk, contribution, rank" + ) + .unwrap(), + (41, 42, 43, 44), + "the fourth return is last week's STANDING" + ); + assert_eq!( + s.eval::<(i64, i64, i64)>( + "local hk, dk, highestRank = GetPVPLifetimeStats() return hk, dk, highestRank" + ) + .unwrap(), + (51, 52, 11) + ); +} + +/// **`GetPVPLifetimeStats` suppresses a highest-lifetime rank below 5** (`0x51a843 cmp al,5; jb`), +/// reporting `0` instead of the true byte — which is why the reference pane shows NONE for a +/// character whose best rank was one of the four dishonorable ones. The boundary is the whole test: +/// 4 vanishes, 5 survives. +#[test] +fn a_lifetime_rank_below_five_is_reported_as_zero() { + let mut s = seated(); + let third = |s: &UiScript| { + s.eval::("return (select(3, GetPVPLifetimeStats()))") + .unwrap() + }; + for (highest, reported) in [(0u8, 0i64), (1, 0), (4, 0), (5, 5), (6, 6), (18, 18)] { + s.set_honor(Some(HonorState { + highest_rank: highest, + ..honor_state() + })); + assert_eq!(third(&s), reported, "highest lifetime rank {highest}"); + } + + // Suppressed to a NUMBER, not to nil: the pane feeds it straight to `GetPVPRankInfo`, which + // raises on a non-number. + s.set_honor(Some(HonorState { + highest_rank: 3, + ..honor_state() + })); + assert_eq!( + s.eval::(r##"return select("#", GetPVPLifetimeStats())"##) + .unwrap(), + 3 + ); + assert!(s + .eval::("return type((select(3, GetPVPLifetimeStats()))) == 'number'") + .unwrap()); +} + +/// Arity, measured the way Lua itself measures it. A getter that returned one value too many +/// would still pass the destructuring test above; `select("#", …)` is what catches it. +#[test] +fn every_getter_returns_exactly_the_reference_arity() { + let s = seated(); + for (call, width) in [ + ("GetPVPSessionStats()", 2), + ("GetPVPYesterdayStats()", 3), + ("GetPVPThisWeekStats()", 2), + ("GetPVPLastWeekStats()", 4), + ("GetPVPLifetimeStats()", 3), + ("GetPVPRankProgress()", 1), + ("GetPVPRankInfo(9)", 2), + ("GetPVPRankInfo(0)", 2), + ("UnitPVPRank('player')", 1), + ] { + assert_eq!( + s.eval::(&format!(r##"return select("#", {call})"##)) + .unwrap(), + width, + "{call} arity" + ); + } +} + +/// Before the first push every self getter still answers at full width, with zeros — the +/// reference feeds these straight into `format()`, so a nil would raise inside its pane. +#[test] +fn the_self_getters_answer_zeroed_before_the_first_push() { + let s = UiScript::new().expect("VM"); + assert_eq!( + s.eval::<(i64, i64, i64, i64)>("return GetPVPLastWeekStats()") + .unwrap(), + (0, 0, 0, 0) + ); + assert_eq!(s.eval::("return GetPVPRankProgress()").unwrap(), 0.0); +} + +/// **The rank bar is a MULTIPLY by a slightly-wrong f32 reciprocal, and it does not clamp.** +/// +/// Written to fail against the natural `byte / 255.0` reading rather than to describe the shape: +/// the constant is asserted at its bits, every byte is compared to the exact product, and the two +/// values a divisor would round prettier (51 → 0.2, 255 → 1.0) are asserted **not** to be those +/// numbers. A full bar comes out *above* 1.0, which is the clamp's absence made visible. +#[test] +fn the_rank_bar_multiplies_by_the_f32_reciprocal_and_never_clamps() { + // The four bytes at `0x8026c8`, and the fact that makes this test necessary at all. + assert_eq!(f64::from(f32::from_bits(0x3B80_8081)), RANK_BAR_SCALE); + assert_ne!(RANK_BAR_SCALE, 1.0 / 255.0, "the f32 is NOT 1/255"); + + let mut s = seated(); + let bar = |s: &UiScript| s.eval::("return GetPVPRankProgress()").unwrap(); + + // 51 is the fixture's byte, and 51/255 would be exactly 0.2. The multiply is not. + assert_eq!(bar(&s), 51.0 * RANK_BAR_SCALE); + assert_ne!(bar(&s), 0.2, "a divisor would land exactly on 0.2"); + assert_ne!(bar(&s), 51.0 / 255.0); + + for byte in [0u8, 1, 51, 128, 254, 255] { + s.set_honor(Some(HonorState { + rank_bar: byte, + ..honor_state() + })); + assert_eq!(bar(&s), f64::from(byte) * RANK_BAR_SCALE, "byte {byte}"); + } + + // The ends. Zero is the one input the two readings agree on; a full bar OVERSHOOTS, and + // nothing clamps it back. + s.set_honor(Some(HonorState { + rank_bar: 0, + ..honor_state() + })); + assert_eq!(bar(&s), 0.0); + s.set_honor(Some(HonorState { + rank_bar: 255, + ..honor_state() + })); + assert!(bar(&s) > 1.0, "255 * K = 1.0000000091389835, unclamped"); + assert_ne!(bar(&s), 1.0); + + // The inspect twin runs the identical kernel over the reply's byte (`0x51ab04`). + s.set_inspect_honor(Some(inspect_state())); // rank_bar 255 + assert_eq!( + s.eval::("return GetInspectPVPRankProgress()").unwrap(), + 255.0 * RANK_BAR_SCALE + ); +} + +/// **Rank 0 names nothing.** There is no `PVP_RANK_0_*` GlobalString, and both reference panes +/// depend on that nil to fall back to `NONE` — so this runs the fallback the way the pane +/// writes it, not just the nil. +#[test] +fn rank_zero_names_nothing_and_the_pane_falls_back_to_none() { + let s = seated(); + seat_rank_globals(&s); + s.lua().globals().set("NONE", "None").expect("NONE"); + + let (name, number) = s + .eval::<(Option, i64)>("local n, r = GetPVPRankInfo(0) return n, r") + .unwrap(); + assert_eq!(name, None, "no PVP_RANK_0_* exists"); + assert_eq!(number, 0, "and the badge (rankNumber > 0) stays off"); + + assert_eq!( + s.eval::( + "local name = GetPVPRankInfo(0) if not name then name = NONE end return name" + ) + .unwrap(), + "None" + ); +} + +/// The title key: the team digit (0 Horde / 1 Alliance) off the player's faction group — and +/// **the pane's title is NOT gendered**, which is the half a plausible implementation gets wrong. +/// +/// `GetPVPRankInfo` hands `0x703bf0` gender 0 (`0x51aa0f`) and takes the fast path, so a female +/// character sees the male/default title in her own honor pane while the `_FEMALE` twin sits right +/// there on `_G`. The seated player is female and a twin exists for rank 9 precisely so that this +/// test fails the moment somebody "fixes" the resolver to prefer it. +#[test] +fn the_pane_title_is_keyed_by_team_and_is_never_gendered() { + let mut s = seated(); + seat_rank_globals(&s); + + // Female Alliance → the BASE key, with `PVP_RANK_9_1_FEMALE` seated and ignored. + assert_eq!( + s.eval::("return (GetPVPRankInfo(9))").unwrap(), + "Sergeant Major" + ); + assert_eq!( + s.eval::("return PVP_RANK_9_1_FEMALE").unwrap(), + "Sergeant Major (f)", + "the twin is on _G — the binding declines it, it does not miss it" + ); + + // Male Alliance → the same answer, which is the point. + s.set_unit("player", Some(UnitState { sex: 2, ..player() })); + assert_eq!( + s.eval::("return (GetPVPRankInfo(9))").unwrap(), + "Sergeant Major" + ); + + // Horde is team 0 — an entirely different word list, which is why an unknown side must + // not be guessed at. + s.set_unit( + "player", + Some(UnitState { + sex: 2, + faction_group: Some("Horde".into()), + ..player() + }), + ); + assert_eq!( + s.eval::("return (GetPVPRankInfo(9))").unwrap(), + "Senior Sergeant" + ); +} + +/// The **Rust-side** title lookup the app's `SMSG_PVP_CREDIT` line uses shares the key +/// construction with the binding and **deliberately differs on gender**: the credit formatter +/// resolves through `0x612bf0` with the local player's gender selector, the pane's binding passes +/// 0. One function each, and the pair of them is asserted apart here so neither drifts into the +/// other. +/// +/// The credit packet carries the INTERNAL rank, which is why its number goes straight in with no +/// visual conversion — and unlike the binding this path applies **no range check**, so rank 19 +/// names "Leader" here and `(nil, 0)` there. +#[test] +fn the_rust_side_title_lookup_is_gendered_where_the_binding_is_not() { + let s = seated(); + seat_rank_globals(&s); + + // The app names a side and a sex outright — no player snapshot involved. + assert_eq!( + s.pvp_rank_title(9, 0, false).as_deref(), + Some("Senior Sergeant") + ); + assert_eq!( + s.pvp_rank_title(9, 1, true).as_deref(), + Some("Sergeant Major (f)"), + "the credit line IS gendered" + ); + // …and the binding, for that same female Alliance player, is not. + assert_ne!( + s.pvp_rank_title(9, 1, true), + s.eval::>("return (GetPVPRankInfo(9))") + .unwrap() + ); + assert_eq!( + s.pvp_rank_title(9, 1, false), + s.eval::>("return (GetPVPRankInfo(9))") + .unwrap(), + "same key construction once the gender is out of it" + ); + + // Gendered means PREFERRED, not required: a rank with no twin falls back to the base key. + assert_eq!(s.pvp_rank_title(5, 1, true).as_deref(), Some("Private")); + + // No range check on this path — rank 19 is a name here and a refusal in the binding. + assert_eq!(s.pvp_rank_title(19, 1, false).as_deref(), Some("Leader")); + assert_eq!(s.pvp_rank_title(19, 1, true).as_deref(), Some("Leader (f)")); + assert!(s.eval::("return GetPVPRankInfo(19) == nil").unwrap()); + + // Same nil semantics either side: rank 0, an absent key, and an empty one. + assert_eq!(s.pvp_rank_title(0, 1, false), None); + assert_eq!(s.pvp_rank_title(17, 1, false), None, "no PVP_RANK_17_1"); + s.lua().globals().set("PVP_RANK_17_1", "").expect("global"); + assert_eq!( + s.pvp_rank_title(17, 1, false), + None, + "empty reads as absent" + ); +} + +/// A missing GlobalString — the bare VM with no install behind it — reads as **nil**, never as +/// an empty string and never as a raise: the panes' `if not rankName` fallback is the only +/// thing standing between that and a blank title. +#[test] +fn a_missing_rank_global_reads_nil_not_empty() { + let s = seated(); // no rank globals seated at all + assert!(s.eval::("return GetPVPRankInfo(9) == nil").unwrap()); + // …and so does an empty one. + s.lua().globals().set("PVP_RANK_9_1", "").expect("global"); + assert!(s.eval::("return GetPVPRankInfo(9) == nil").unwrap()); + // A player with no side names nothing either, rather than picking a list. + let mut s = s; + s.set_unit( + "player", + Some(UnitState { + faction_group: None, + ..player() + }), + ); + seat_rank_globals(&s); + assert!(s.eval::("return GetPVPRankInfo(9) == nil").unwrap()); +} + +/// The internal→visual conversion across its whole range — **and the negative half runs the +/// opposite way from the server's `visualRank`**, which is the assertion that matters: rank 1 is +/// −4 and rank 4 is −1, because `0x51aa38` subtracts 5 rather than negating. vmangos's +/// `HonorMgr.cpp:991` gives 1 → −1 and 4 → −4; the binding's second return is the client's, and +/// this pins it so nobody restores the server's arithmetic here. +#[test] +fn the_visual_rank_arithmetic_runs_backwards_through_the_dishonorable_ranks() { + for (internal, visual) in [ + (1i64, -4i64), + (2, -3), + (3, -2), + (4, -1), + (5, 1), + (9, 5), + (18, 14), + ] { + assert_eq!(visual_rank(internal), visual, "internal {internal}"); + // The server's form, stated as the thing this is NOT. + let servers = if internal > 4 { + internal - 4 + } else { + -internal + }; + assert_eq!( + servers == visual, + internal >= 5, + "the two forms agree above rank 4 and disagree below it" + ); + } + + // …and the binding reports the same number, which is what indexes the badge texture: only a + // positive one is drawn, so all four dishonorable ranks hide it. + let s = seated(); + seat_rank_globals(&s); + for (internal, visual) in [(1i64, -4i64), (4, -1), (5, 1), (18, 14)] { + assert_eq!( + s.eval::(&format!("return (select(2, GetPVPRankInfo({internal})))")) + .unwrap(), + visual, + "GetPVPRankInfo({internal})" + ); + } +} + +/// **The range gate is `[1, 18]`, so rank 19 — "Leader" — is refused**, GlobalString and all. +/// +/// The consequence is real and visible: the engine's own badge table has fifteen entries because +/// rank 19 → `PvPRank15` is reachable from the world-text kill toast (`0x6c7f10`, index +/// `rank − 5`), never from this binding, which is why FrameXML only ever names fourteen. +/// +/// Both failure edges answer `(nil, 0)` — **two** values, never one — because the panes destructure +/// a pair and then branch on the nil. +#[test] +fn the_range_gate_refuses_rank_zero_and_rank_nineteen_alike() { + let s = seated(); + seat_rank_globals(&s); + + for rank in [0i64, 19, 20, -1, 255] { + let (name, number) = s + .eval::<(Option, i64)>(&format!( + "local n, r = GetPVPRankInfo({rank}) return n, r" + )) + .unwrap(); + assert_eq!(name, None, "rank {rank} names nothing"); + assert_eq!(number, 0, "rank {rank} numbers 0"); + assert_eq!( + s.eval::(&format!(r##"return select("#", GetPVPRankInfo({rank}))"##)) + .unwrap(), + 2, + "rank {rank} still answers two values" + ); + } + + // The refusal is the GATE, not a missing key: `PVP_RANK_19_1` is right there. + assert_eq!(s.eval::("return PVP_RANK_19_1").unwrap(), "Leader"); + // …and the ends of the accepted range do answer. + assert!(s.eval::("return GetPVPRankInfo(1) ~= nil").unwrap()); + assert!(s.eval::("return GetPVPRankInfo(18) ~= nil").unwrap()); +} + +/// **The second argument**, which the reference's own panes never pass and which is therefore easy +/// to miss entirely: a NUMBER is the team digit itself, a STRING is a unit token, and absent means +/// the local player. +#[test] +fn get_pvp_rank_info_takes_a_second_argument_three_different_ways() { + let mut s = seated(); // an ALLIANCE player + seat_rank_globals(&s); + s.set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Thrall".into()), + is_player: true, + faction_group: Some("Horde".into()), + pvp_rank: 14, + ..Default::default() + }), + ); + + // Absent → the local player's side. + assert_eq!( + s.eval::("return (GetPVPRankInfo(9))").unwrap(), + "Sergeant Major" + ); + // A NUMBER is the digit, with no unit resolved at all — including a digit the player is not. + assert_eq!( + s.eval::("return (GetPVPRankInfo(9, 0))").unwrap(), + "Senior Sergeant" + ); + assert_eq!( + s.eval::("return (GetPVPRankInfo(9, 1))").unwrap(), + "Sergeant Major" + ); + // Lua's `isnumber` accepts a numeric string, so "0" is a DIGIT and not a token. + assert_eq!( + s.eval::(r#"return (GetPVPRankInfo(9, "0"))"#) + .unwrap(), + "Senior Sergeant" + ); + // …and truncates toward zero, like every other numeric argument. + assert_eq!( + s.eval::("return (GetPVPRankInfo(9, 0.9))").unwrap(), + "Senior Sergeant" + ); + // A team digit no GlobalString matches simply misses. + assert!(s + .eval::("return GetPVPRankInfo(9, 7) == nil") + .unwrap()); + + // A STRING is a unit token — the FOREIGN unit's side, not the player's. + assert_eq!( + s.eval::(r#"return (GetPVPRankInfo(9, "target"))"#) + .unwrap(), + "Senior Sergeant", + "Thrall is Horde, so his ladder names rank 9" + ); + + // A token that names nothing, and a token naming a non-player, both fall to team 0 — the + // engine's uninitialised team register, not a failure. + assert_eq!( + s.eval::(r#"return (GetPVPRankInfo(9, "mouseover"))"#) + .unwrap(), + "Senior Sergeant" + ); + s.set_unit( + "mouseover", + Some(UnitState { + exists: true, + name: Some("Timber Wolf".into()), + faction_group: Some("Alliance".into()), + ..Default::default() + }), + ); + assert_eq!( + s.eval::(r#"return (GetPVPRankInfo(9, "mouseover"))"#) + .unwrap(), + "Senior Sergeant", + "a non-player is gated out before its side is read" + ); + + // A unit that resolves but has NO side is −1, which misses the key — a different answer from + // the not-found 0 above, and the one the panes render as NONE. + s.set_unit( + "target", + Some(UnitState { + exists: true, + is_player: true, + faction_group: None, + ..Default::default() + }), + ); + assert!(s + .eval::(r#"return GetPVPRankInfo(9, "target") == nil"#) + .unwrap()); + + // An unrecognised token raises, as it does for every unit binding. + assert!(s + .eval::>(r#"return (GetPVPRankInfo(9, "wombat"))"#) + .is_err()); +} + +/// The first argument's `Usage:` gate — `0x51a930` tests `lua_isnumber` and **raises**, abandoning +/// the caller's statement. It is not a `(nil, 0)` edge, and the two are not interchangeable. +#[test] +fn get_pvp_rank_info_raises_without_a_numeric_first_argument() { + let s = seated(); + for call in [ + "GetPVPRankInfo()", + "GetPVPRankInfo({})", + "GetPVPRankInfo(nil)", + ] { + let err = s + .eval::>(&format!("return ({call})")) + .expect_err(call); + assert!( + format!("{err}").contains("Usage: GetPVPRankInfo(rank [, unit])"), + "{call}: {err}" + ); + } + // A numeric string is a number to Lua, so it does NOT raise. + assert!(s + .eval::>(r#"return (GetPVPRankInfo("9"))"#) + .is_ok()); +} + +/// `UnitPVPRank` must answer for a **foreign** unit: `PLAYER_BYTES_3` is PUBLIC, and the +/// reference's inspect pane calls it as `UnitPVPRank("target")`. An unknown token reads 0. +#[test] +fn unit_pvp_rank_answers_for_a_foreign_unit() { + let mut s = seated(); + s.set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Thrall".into()), + is_player: true, + faction_group: Some("Horde".into()), + pvp_rank: 14, + ..Default::default() + }), + ); + assert_eq!( + s.eval::(r#"return UnitPVPRank("target")"#).unwrap(), + 14 + ); + assert_eq!(s.eval::(r#"return UnitPVPRank("player")"#).unwrap(), 9); + // A creature reads 0 because it has no PLAYER descriptor block to decode a rank byte from — + // the engine's own path to the same answer. The binding does NOT re-gate on `is_player`; see + // the binding for why our `is_player` is not the engine's type mask. + s.set_unit( + "mouseover", + Some(UnitState { + exists: true, + name: Some("Timber Wolf".into()), + is_player: false, + ..Default::default() + }), + ); + assert_eq!( + s.eval::(r#"return UnitPVPRank("mouseover")"#).unwrap(), + 0 + ); + s.set_unit("mouseover", None); + assert_eq!( + s.eval::(r#"return UnitPVPRank("mouseover")"#).unwrap(), + 0, + "no snapshot reads 0, like every other numeric Unit* getter" + ); + // A non-string argument RAISES (`0x51a8ac`) — it does not answer 0. A number is a string to + // Lua, so it resolves the token "5" and raises for a different reason: an unknown token. + assert!(s.eval::("return UnitPVPRank()").is_err()); + assert!(s.eval::("return UnitPVPRank({})").is_err()); +} + +/// **`UnitPVPName`'s three legs** (`0x609370`), which is the whole binding: the decorated player +/// name, the civilian prefix, and the plain name. +/// +/// The template is `UNIT_PVP_NAME` off `_G` and it is filled rank-FIRST — `add esp,0x14` proves two +/// varargs and enUS ships `"%s %s"`. The title here **is** gendered (by this unit), which is the +/// opposite of the pane's and the reason the two lookups are separate functions. +#[test] +fn unit_pvp_name_decorates_a_ranked_player_and_falls_back_three_ways() { + let mut s = seated(); + seat_rank_globals(&s); + seat_name_globals(&s); + + // Leg A — the seated player is a female Alliance rank 9, so the FEMALE twin wins here. + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Sergeant Major (f) Benilla" + ); + // …male takes the base key, and the order is rank then name either way. + s.set_unit("player", Some(UnitState { sex: 2, ..player() })); + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Sergeant Major Benilla" + ); + + // No range check on this path: the racial-leader rank really does render. + s.set_unit( + "player", + Some(UnitState { + sex: 2, + pvp_rank: 19, + ..player() + }), + ); + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Leader Benilla", + "rank 19 names here and is refused by GetPVPRankInfo" + ); + + // Leg A′ — the city-protector medal, appended on its own line. + s.set_unit( + "player", + Some(UnitState { + sex: 2, + pvp_medal: 1, + ..player() + }), + ); + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Sergeant Major Benilla\nGuardian of Stormwind" + ); + + // Leg C — an unranked player is just a name, and so is a plain creature. + s.set_unit( + "player", + Some(UnitState { + pvp_rank: 0, + ..player() + }), + ); + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Benilla" + ); + s.set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Timber Wolf".into()), + level: 5, + ..Default::default() + }), + ); + assert_eq!( + s.eval::(r#"return UnitPVPName("target")"#).unwrap(), + "Timber Wolf" + ); + + // Leg B — the civilian prefix, on the same `0x612550` gate the tooltip's CIVILIAN line uses: + // PvP-flagged, hostile and grey to the player. A friendly civilian is still just a name. + s.set_player_req_state(crate::script::PlayerReqState { + level: 30, + ..Default::default() + }); + let civilian = |reaction: u8, pvp: bool| UnitState { + exists: true, + name: Some("Innkeeper Renee".into()), + level: 5, + civilian: true, + pvp, + reaction, + ..Default::default() + }; + s.set_unit("target", Some(civilian(2, true))); + assert_eq!( + s.eval::(r#"return UnitPVPName("target")"#).unwrap(), + "Civilian Innkeeper Renee" + ); + s.set_unit("target", Some(civilian(5, true))); + assert_eq!( + s.eval::(r#"return UnitPVPName("target")"#).unwrap(), + "Innkeeper Renee", + "a FRIENDLY civilian is not a dishonorable kill" + ); + s.set_unit("target", Some(civilian(2, false))); + assert_eq!( + s.eval::(r#"return UnitPVPName("target")"#).unwrap(), + "Innkeeper Renee", + "…nor an unflagged one" + ); + + // The two nil edges, and the raise. + assert!(s + .eval::(r#"return UnitPVPName("mouseover") == nil"#) + .unwrap()); + s.set_unit( + "mouseover", + Some(UnitState { + exists: true, + name: None, + ..Default::default() + }), + ); + assert!( + s.eval::(r#"return UnitPVPName("mouseover") == nil"#) + .unwrap(), + "a snapshot whose name has not resolved is nil, not an empty decoration" + ); + assert!(s.eval::("return UnitPVPName()").is_err()); +} + +/// With no install behind the VM there is no template and no title, and the decoration is install +/// data: we answer the **plain name**. (The engine would snprintf through an empty format and hand +/// back an empty string — a stated divergence, not an oversight.) +#[test] +fn unit_pvp_name_without_the_globalstrings_answers_the_plain_name() { + let s = seated(); // nothing seated on _G + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Benilla" + ); + // The template alone is not enough — the title has to resolve too. + s.lua() + .globals() + .set("UNIT_PVP_NAME", "%s %s") + .expect("global"); + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + "Benilla" + ); +} + +/// The template is *filled*, not assumed: a locale that punctuates `UNIT_PVP_NAME` differently +/// comes out differently, and a `%%` is a literal per cent. Two varargs is the engine's own limit +/// (`add esp,0x14`), so a third specifier has nothing to consume. +#[test] +fn the_two_string_template_is_substituted_not_hardcoded() { + assert_eq!(format_two_strings("%s %s", "Rank", "Name"), "Rank Name"); + assert_eq!(format_two_strings("%s, %s!", "Rank", "Name"), "Rank, Name!"); + assert_eq!(format_two_strings("%s%s", "Rank", "Name"), "RankName"); + assert_eq!(format_two_strings("100%% %s %s", "R", "N"), "100% R N"); + assert_eq!( + format_two_strings("no specifiers", "R", "N"), + "no specifiers" + ); + assert_eq!(format_two_strings("%s %s %s", "R", "N"), "R N "); + assert_eq!(format_two_strings("%d %s %s", "R", "N"), "%d R N"); + assert_eq!(format_two_strings("trailing %", "R", "N"), "trailing %"); + + let s = seated(); + seat_rank_globals(&s); + seat_name_globals(&s); + s.lua() + .globals() + .set("UNIT_PVP_NAME", "<%s> %s") + .expect("global"); + assert_eq!( + s.eval::(r#"return UnitPVPName("player")"#).unwrap(), + " Benilla" + ); +} + +/// The inspect latch: false before any reply, true while one is held, false again once the app +/// clears it — which is what makes the reference's `OnShow` re-request for the next player. +#[test] +fn has_inspect_honor_data_latches_on_the_push_and_clears_with_it() { + let mut s = seated(); + let held = || r#"return HasInspectHonorData() and true or false"#; + + assert!(!s.eval::(held()).unwrap()); + s.set_inspect_honor(Some(inspect_state())); + assert!(s.eval::(held()).unwrap()); + + // `0x4c95e0` pushes the NUMBER 1, not a boolean — one value either way, and the truthy arm is + // `1` rather than `true`. (The carve says `lua_pushnumber(1.0)`; in a VM whose numbers are all + // doubles there is no observable integer/float distinction, and `tostring` proves it.) + assert_eq!( + s.eval::("return type(HasInspectHonorData())") + .unwrap(), + "number" + ); + assert_eq!( + s.eval::("return tostring(HasInspectHonorData())") + .unwrap(), + "1" + ); + + s.set_inspect_honor(None); + assert!(!s.eval::(held()).unwrap()); +} + +/// The reference's own twelve-wide destructure, and the standing sitting ninth. +#[test] +fn get_inspect_honor_data_returns_the_twelve_in_the_reference_order() { + let mut s = seated(); + s.set_inspect_honor(Some(inspect_state())); + + assert_eq!( + s.eval::(r##"return select("#", GetInspectHonorData())"##) + .unwrap(), + 12 + ); + let got = s + .eval::>( + "local sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisweekHK, \ + thisweekHonor, lastweekHK, lastweekHonor, lastweekStanding, lifetimeHK, \ + lifetimeDK, lifetimeRank = GetInspectHonorData() \ + return {sessionHK, sessionDK, yesterdayHK, yesterdayHonor, thisweekHK, \ + thisweekHonor, lastweekHK, lastweekHonor, lastweekStanding, lifetimeHK, \ + lifetimeDK, lifetimeRank}", + ) + .unwrap(); + assert_eq!(got, [61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 12]); + + assert_eq!( + s.eval::("return GetInspectPVPRankProgress()").unwrap(), + 255.0 * RANK_BAR_SCALE, + "the reply's own rankBar byte, not the player's" + ); +} + +/// **With no reply held `GetInspectHonorData` still answers twelve — zeros.** `0x4c9620` is +/// UNGATED: it never consults the has-data flag, and the sixteen globals it reads are +/// zero-initialised BSS. A short return would leave the pane painting `SetText(nil)` — a blank row +/// — where the real client paints a `0` row, and the two look different on screen. +#[test] +fn get_inspect_honor_data_answers_twelve_zeros_when_no_reply_is_held() { + let mut s = seated(); + assert_eq!( + s.eval::(r##"return select("#", GetInspectHonorData())"##) + .unwrap(), + 12, + "ungated: twelve on every path" + ); + assert_eq!( + s.eval::>("return {GetInspectHonorData()}") + .unwrap(), + vec![0; 12] + ); + assert!(s + .eval::("return (GetInspectHonorData()) == 0") + .unwrap()); + // The bar reads its own zeroed byte, for the same reason. + assert_eq!( + s.eval::("return GetInspectPVPRankProgress()").unwrap(), + 0.0 + ); + + // And after the app drops the reply it is zeros again — our `Option` is the latch AND the + // data, where the engine keeps them apart and would answer the previous target's numbers + // here (§5.1/§5.2: re-keying zeroes the two flags and leaves the sixteen slots alone). + s.set_inspect_honor(Some(inspect_state())); + s.set_inspect_honor(None); + assert_eq!( + s.eval::>("return {GetInspectHonorData()}") + .unwrap(), + vec![0; 12] + ); +} + +/// The two intent queues drain to the app and reset — and the honor query **does not queue twice**: +/// `0x4c80a0` bails while one is in flight (`pending`) and bails again once data is held +/// (`hasData`), which is what stops a pane shown/hidden/shown sending duplicates. The PvP toggle +/// has no such latch and genuinely does send twice. +#[test] +fn the_intent_queues_drain_and_the_honor_query_refuses_to_double_up() { + let mut s = seated(); + assert_eq!(s.take_inspect_honor_requests(), 0); + + // Two calls, ONE query: the second sees `pending`. + s.eval::<()>("RequestInspectHonorData() RequestInspectHonorData()") + .unwrap(); + assert_eq!(s.take_inspect_honor_requests(), 1); + assert_eq!(s.take_inspect_honor_requests(), 0); + + // Still latched after the drain — the app has sent, nothing has replied. + s.eval::<()>("RequestInspectHonorData()").unwrap(); + assert_eq!(s.take_inspect_honor_requests(), 0, "still in flight"); + + // The reply clears `pending` — and immediately sets `hasData`, so the next ask is refused for + // the *other* reason. + s.set_inspect_honor(Some(inspect_state())); + s.eval::<()>("RequestInspectHonorData()").unwrap(); + assert_eq!(s.take_inspect_honor_requests(), 0, "data already held"); + + // Dropping the inspected player clears both, and the next ask goes out. + s.set_inspect_honor(None); + s.eval::<()>("RequestInspectHonorData()").unwrap(); + assert_eq!(s.take_inspect_honor_requests(), 1); + + // The binding answers zero Lua values on every one of those paths (`0x4c9610`). + assert_eq!( + s.eval::(r##"return select("#", RequestInspectHonorData())"##) + .unwrap(), + 0 + ); + + s.eval::<()>("TogglePVP() TogglePVP()").unwrap(); + assert_eq!(s.take_pvp_toggles(), 2, "no latch here — two packets"); + assert_eq!(s.take_pvp_toggles(), 0); +} diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index 458fa9fa..1dd83334 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -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 { } // 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. diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 48255c31..089d61a1 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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` 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