From ea1f0d2bb53b7e867f3c974d57fd7441f458f703 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 19:09:04 +0700 Subject: [PATCH] reputation arrives: the wire, the pane and a live watch bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the Character window's third tab is in: faction rows under their headers, standing bars, the detail popup and the watch bar, at the reference's own anchors - all four reputation opcodes parse byte-exact, the flag byte reads the way the client reads it, and a standing gain reveals the faction unless it is hidden - an end-to-end golden drives live wire bytes through the game data to the exact visible row list - the reference-code overlap is measured by an instrument instead of asserted — the start of a debt paid in full later in the day Co-Authored-By: Claude Fable 5 --- README.md | 19 +- crates/benilla-app/assets/ui/ActionBar.xml | 375 ++++++- .../benilla-app/assets/ui/CharacterFrame.xml | 76 +- .../assets/ui/PetPaperDollFrame.xml | 16 +- .../benilla-app/assets/ui/ReputationFrame.xml | 864 ++++++++++++++++ crates/benilla-app/assets/ui/SkillFrame.xml | 16 +- crates/benilla-app/assets/ui/UIParent.xml | 15 +- crates/benilla-app/assets/ui/benilla.toc | 21 +- crates/benilla-app/src/lib.rs | 5 + crates/benilla-app/src/net.rs | 25 +- crates/benilla-app/src/net/apply.rs | 3 + crates/benilla-app/src/net/apply/session.rs | 30 +- crates/benilla-app/src/net/io.rs | 11 + crates/benilla-app/src/ui_reputation.rs | 593 +++++++++++ .../src/ui_script/action_bar_tests.rs | 4 +- .../src/ui_script/character_tests.rs | 20 +- crates/benilla-app/src/ui_script/mod.rs | 3 + .../src/ui_script/reputation_frame_tests.rs | 649 ++++++++++++ crates/benilla-formats/src/factions.rs | 211 +++- crates/benilla-formats/src/lib.rs | 3 +- crates/benilla-protocol/src/events.rs | 4 + crates/benilla-protocol/src/events/decode.rs | 3 + crates/benilla-protocol/src/messages/mod.rs | 4 + .../benilla-protocol/src/messages/opcode.rs | 10 + .../benilla-protocol/src/messages/packet.rs | 8 + crates/benilla-protocol/src/messages/parse.rs | 3 + .../src/messages/reputation.rs | 53 + .../src/messages/update_object/fields/mod.rs | 8 + .../messages/update_object/fields/player.rs | 10 + .../messages/update_object/fields/tests.rs | 7 + .../benilla-protocol/src/world/writer/mod.rs | 1 + .../src/world/writer/reputation.rs | 42 + crates/benilla-protocol/tests/reputation.rs | 80 ++ crates/benilla-ui/src/script/mod.rs | 3 + crates/benilla-ui/src/script/model.rs | 31 +- crates/benilla-ui/src/script/reputation.rs | 934 ++++++++++++++++++ 36 files changed, 4013 insertions(+), 147 deletions(-) create mode 100644 crates/benilla-app/assets/ui/ReputationFrame.xml create mode 100644 crates/benilla-app/src/ui_reputation.rs create mode 100644 crates/benilla-app/src/ui_script/reputation_frame_tests.rs create mode 100644 crates/benilla-protocol/src/messages/reputation.rs create mode 100644 crates/benilla-protocol/src/world/writer/reputation.rs create mode 100644 crates/benilla-protocol/tests/reputation.rs create mode 100644 crates/benilla-ui/src/script/reputation.rs diff --git a/README.md b/README.md index d6e93386..6ce3c41a 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,16 @@ Early inspiration and file format guidance came from the benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment. It ships **no Blizzard game data** — no art, models, sounds, maps or MPQ contents — and you provide -your own legally obtained 1.12.1 client for all of it. Parts of the interface layer under -`crates/benilla-app/assets/ui/` are **transcribed from the client's own FrameXML** (its Lua and XML), -cited in place with the reference file and line range; that is Blizzard's authorship, not ours, and -it is not covered by the licence below. World of Warcraft is a trademark of Blizzard Entertainment, -Inc. Our own code is licensed under [MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your -option. +your own legally obtained 1.12.1 client for all of it. + +**One known exception, which is being removed.** Some interface logic under +`crates/benilla-app/assets/ui/` is transcribed from the client's own FrameXML rather than written +from scratch. It is measured, not estimated — `scripts/reference-overlap.py` reports every run of +code identical to the reference — and the work to eliminate it is by replacing those blocks with our +own implementations, or by executing the player's own installed file at runtime instead of shipping +a copy (the mechanism already used for `GlobalStrings.lua` and `ContainerFrame.lua`). Until that +measurement reaches zero, treat those blocks as Blizzard's authorship rather than ours, and note +that the public snapshot is gated on it. + +World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under +[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml index 95544f42..86e06806 100644 --- a/crates/benilla-app/assets/ui/ActionBar.xml +++ b/crates/benilla-app/assets/ui/ActionBar.xml @@ -924,54 +924,190 @@ ExhaustionTick.timer = nil end - -- ── The max-level rail (ref-ReputationFrame.lua, the max-level slice) ───────────────── - -- ref l.14: the constant lives in ReputationFrame.lua, which isn't built yet — it moves - -- there with the reputation system. - MAX_PLAYER_LEVEL = 60 + -- ══ The reputation WATCH BAR (ref-ReputationFrame.xml l.869-1036 + ref-ReputationFrame.lua + -- l.3-14 and l.161-270) ═══════════════════════════════════════════════════════════════ + -- + -- **THE PLACEMENT IS A DELIBERATE CALL, and this is its justification.** The reference + -- declares `ReputationWatchBar` and defines its update, its two text verbs, MAX_PLAYER_LEVEL + -- and FACTION_BAR_COLORS in `ReputationFrame.{xml,lua}`, because FrameXML loads MainMenuBar + -- and ReputationFrame in one breath and neither can exist without the other. benilla's + -- manifest is not one breath: this file is entry 94, `ReputationFrame.xml` is 213, and the + -- engine's per-window test harnesses deliberately load FILE SUBSETS — the XP bar's harness + -- loads no character window, the character window's loads no action bar. + -- + -- Every frame `ReputationWatchBar_Update` touches except the watch bar itself is declared + -- in THIS file: MainMenuExpBar, MainMenuBarMaxLevelBar, ExhaustionTick, MainMenuBarArtFrame + -- and MainMenuBar. So does every frame that reads the bar back: `ExhaustionTick_Update` + -- below consults `ReputationWatchBar:IsShown()` twice, which is the reference's own pair of + -- guards. Splitting the bar's FRAME from the bar's UPDATE across a 120-entry manifest gap + -- would leave each subset holding half a mechanism and would put a nil-guard on every one + -- of those sites — the exact "special case to dodge a problem" shape the contract forbids. + -- The bar therefore stays whole, in the file that owns its collaborators, and + -- `ReputationFrame.xml` — which owns the PANE — reaches `ReputationWatchBar_Update()` by + -- name at click time, the direction that always resolves. + -- + -- What DID move is the half-transcription. `BenillaMaxLevelBar_Update` was explicitly "the + -- no-watched-faction branch only" of the reference function, parked on the rail frame + -- because the watch bar was driven by nothing; it is gone, replaced in place by the whole + -- `ReputationWatchBar_Update(newLevel)`. `MainMenuBarMaxLevelBar` accordingly carries NO + -- scripts any more — the reference's rail carries none either, because this function is its + -- only driver — and the event fan that sat on it is now the watch bar's own OnLoad/OnEvent, + -- which is where the reference puts it. + MAX_PLAYER_LEVEL = 60 -- ref-ReputationFrame.lua l.14 (with the ref's own "Hard coded =(") - function BenillaMaxLevelBar_OnLoad(bar) - bar:RegisterEvent("PLAYER_ENTERING_WORLD") - bar:RegisterEvent("PLAYER_LEVEL_UP") - end + -- ref-ReputationFrame.lua l.3-12, verbatim. Indexed by the 1-based standingID both + -- `GetFactionInfo` and `GetWatchedFactionInfo` report (benilla-ui `script::reputation`), so + -- the pane's 15 bars and this one bar can never disagree about a colour. Declared HERE + -- rather than in ReputationFrame.xml (its reference home) for the placement reason above: + -- this file must be able to index it with no reputation file loaded, and ReputationFrame.xml + -- cannot be loaded without this one. + FACTION_BAR_COLORS = { + [1] = { r = 0.8, g = 0.3, b = 0.22 }, + [2] = { r = 0.8, g = 0.3, b = 0.22 }, + [3] = { r = 0.75, g = 0.27, b = 0 }, + [4] = { r = 0.9, g = 0.7, b = 0 }, + [5] = { r = 0, g = 0.6, b = 0.1 }, + [6] = { r = 0, g = 0.6, b = 0.1 }, + [7] = { r = 0, g = 0.6, b = 0.1 }, + [8] = { r = 0, g = 0.6, b = 0.1 }, + } - -- ref ReputationWatchBar_Update's else arm (ref-ReputationFrame.lua l.161-166 + - -- l.236-244), the no-watched-faction branch only — the reputation branches wait for the - -- reputation system: at MAX_PLAYER_LEVEL the XP strip gives way to the rail and the - -- tick goes with it. Login seam: the ref initializes off UPDATE_FACTION (its OnEvent's - -- else arm; the faction list lands at every login), an event benilla doesn't carry yet, - -- so the rail listens to PLAYER_ENTERING_WORLD instead — same moment, same values. - function BenillaMaxLevelBar_Update(newLevel) + -- ref-ReputationFrame.lua ReputationWatchBar_Update (l.161-250), whole. Three states, and + -- the level decides between the first two: a watched faction below max level STACKS the + -- reputation strip on top of the XP bar (8px tall, sitting 3 above MainMenuBar, wearing the + -- UI-ReputationWatchBar end art); a watched faction AT max level REPLACES the XP bar (13px + -- tall, seated on MainMenuBar's own TOP, wearing the dwarf XP-bar art instead so the swap is + -- invisible); no watched faction leaves the XP bar (or, at max level, the brass rail) alone. + -- + -- `newLevel` is the reference's own parameter: PLAYER_LEVEL_UP carries the new level as + -- arg1 because the level FIELD may not have landed yet, and every other caller re-reads + -- UnitLevel. + -- + -- ONE GUARD ADDED, at the tail: `updateContainerFrameAnchors` is defined by + -- ContainerFrameAdapters.xml (manifest 206, below this file), and a harness that shows this + -- bar without it would take a nil call on a line that only ever runs on a visibility flip. + -- The `if then` shape is this codebase's own subset-load idiom (CharacterFrame.xml's + -- `if UpdateMicroButtons then`). `UIParent_ManageFramePositions` needs no such guard: + -- UIParent.xml is manifest 42, above every consumer. + function ReputationWatchBar_Update(newLevel) + local name, reaction, min, max, value = GetWatchedFactionInfo() + local visibilityChanged = nil if not newLevel then newLevel = UnitLevel("player") end - if newLevel == MAX_PLAYER_LEVEL then - MainMenuExpBar:Hide() - MainMenuBarMaxLevelBar:Show() - ExhaustionTick:Hide() + if name then + -- See if it was already shown or not (ref l.168-171). + if not ReputationWatchBar:IsShown() then + visibilityChanged = 1 + end + + -- Normalize values (ref l.173-176). The engine reports these ABSOLUTE for exactly + -- this reason (benilla-ui `script::reputation`'s module doc). + max = max - min + value = value - min + min = 0 + ReputationWatchStatusBar:SetMinMaxValues(min, max) + ReputationWatchStatusBar:SetValue(value) + ReputationWatchStatusBarText:SetText(name .. " " .. value .. " / " .. max) + local color = FACTION_BAR_COLORS[reaction] + ReputationWatchStatusBar:SetStatusBarColor(color.r, color.g, color.b) + ReputationWatchBar:Show() + + -- ref l.184-230: below max level the strip stacks on the XP bar; at max level it + -- replaces it. + ReputationWatchStatusBar:SetFrameLevel(MainMenuBarArtFrame:GetFrameLevel() - 1) + if newLevel < MAX_PLAYER_LEVEL then + ReputationWatchStatusBar:SetHeight(8) + ReputationWatchBar:ClearAllPoints() + ReputationWatchBar:SetPoint("BOTTOM", MainMenuBar, "TOP", 0, -3) + ReputationWatchStatusBarText:SetPoint("CENTER", ReputationWatchBarOverlayFrame, "CENTER", 0, 3) + ReputationWatchBarTexture0:Show() + ReputationWatchBarTexture1:Show() + ReputationWatchBarTexture2:Show() + ReputationWatchBarTexture3:Show() + + ReputationXPBarTexture0:Hide() + ReputationXPBarTexture1:Hide() + ReputationXPBarTexture2:Hide() + ReputationXPBarTexture3:Hide() + + MainMenuExpBar:Show() + MainMenuBarMaxLevelBar:Hide() + else + ReputationWatchStatusBar:SetHeight(13) + ReputationWatchBar:ClearAllPoints() + ReputationWatchBar:SetPoint("TOP", MainMenuBar, "TOP", 0, 0) + ReputationWatchStatusBarText:SetPoint("CENTER", ReputationWatchBarOverlayFrame, "CENTER", 0, 1) + ReputationWatchBarTexture0:Hide() + ReputationWatchBarTexture1:Hide() + ReputationWatchBarTexture2:Hide() + ReputationWatchBarTexture3:Hide() + + ReputationXPBarTexture0:Show() + ReputationXPBarTexture1:Show() + ReputationXPBarTexture2:Show() + ReputationXPBarTexture3:Show() + + ExhaustionTick:Hide() + + MainMenuExpBar:Hide() + MainMenuBarMaxLevelBar:Hide() + end else - MainMenuExpBar:Show() - MainMenuBarMaxLevelBar:Hide() + -- ref l.232-245: nothing watched. This is the arm decision 1094 shipped alone. + if ReputationWatchBar:IsShown() then + visibilityChanged = 1 + end + ReputationWatchBar:Hide() + if newLevel == MAX_PLAYER_LEVEL then + MainMenuExpBar:Hide() + MainMenuBarMaxLevelBar:Show() + ExhaustionTick:Hide() + else + MainMenuExpBar:Show() + MainMenuBarMaxLevelBar:Hide() + end + end + if visibilityChanged then + UIParent_ManageFramePositions() + if updateContainerFrameAnchors then updateContainerFrameAnchors() end end end - -- The ref's event fan (ref-ReputationFrame.xml l.1002-1016): PLAYER_LEVEL_UP passes - -- arg1 (the new level — the level FIELD may not have landed yet), everything else - -- re-reads UnitLevel. - function BenillaMaxLevelBar_OnEvent() - if event == "PLAYER_LEVEL_UP" then - BenillaMaxLevelBar_Update(arg1) + -- ref-ReputationFrame.lua l.252-270, verbatim. The pair is a two-key lock: `cvarLocked` + -- records that the statusBarText cvar (not a hover) asked for the text, and `textLocked` + -- that the text is currently pinned on — which is what the bar's own OnLeave reads before + -- hiding it again. Note the reference only ever shows the text AT MAX LEVEL, where the + -- strip has replaced the XP bar and is tall enough to hold numerals; below that the Show + -- verb falls through to the Hide one. + function ShowWatchedReputationBarText(lock) + if lock then + ReputationWatchBar.cvarLocked = lock + end + if UnitLevel("player") == MAX_PLAYER_LEVEL and ReputationWatchBar:IsVisible() then + ReputationWatchStatusBarText:Show() + ReputationWatchBar.textLocked = 1 else - BenillaMaxLevelBar_Update() + HideWatchedReputationBarText() + end + end + + function HideWatchedReputationBarText(unlock) + if unlock or not ReputationWatchBar.cvarLocked then + ReputationWatchBar.cvarLocked = nil + ReputationWatchStatusBarText:Hide() + ReputationWatchBar.textLocked = nil end end -- ── The exhaustion tick (ref-MainMenuBar.lua ExhaustionTick_*) ──────────────────────── -- The round marker riding the XP bar at the point where rested XP runs out, plus the - -- pale fill between the bar's value and that point. One transcription cut left, named: - -- the ref's two ReputationWatchBar guards (the max-level+rep tick hide, l.51-54, and - -- the rep-bar-replaced-the-xp-bar hide, l.69-71) reference the faction watch bar, not - -- built — its frame can't be shown, so the cut is an unreachable-code cut, not a - -- behavior cut. The MainMenuBarMaxLevelBar guard is live since 1094 (the rail below). + -- pale fill between the bar's value and that point. NO transcription cuts left: the two + -- ReputationWatchBar guards that used to be cut here (the max-level-plus-rep tick hide, + -- ref-MainMenuBar.lua l.51-54, and the rep-bar-replaced-the-xp-bar hide, l.69-71) were cut + -- as UNREACHABLE — the watch bar could never show — and the watch bar above shows now, so + -- both are transcribed into ExhaustionTick_Update below. The MainMenuBarMaxLevelBar guard + -- is live since 1094. function ExhaustionTick_OnLoad() local tick = this tick:RegisterEvent("PLAYER_ENTERING_WORLD") @@ -1014,6 +1150,11 @@ "TOPLEFT", exhaustionTickSet, 0) end end + -- ref-MainMenuBar.lua l.51-54: at max level with a watched faction the strip the + -- tick rides is the REPUTATION bar, which has no rested pool — hide the tick. + if UnitLevel("player") == MAX_PLAYER_LEVEL and ReputationWatchBar:IsShown() then + ExhaustionTick:Hide() + end end -- The rest-state paint (only these two events repaint it, the ref's own split): -- rested = light blue bar + pale blue fill-to-tick, normal = the purple default. @@ -1029,6 +1170,13 @@ ExhaustionTickHighlight:SetVertexColor(0.58, 0.0, 0.55) end end + -- ref-MainMenuBar.lua l.69-71, the function's own last statement: the watch bar has + -- REPLACED the XP bar (max level), so there is no XP strip left for the tick to sit on + -- whatever the event was. Distinct from the guard above, which only runs on the four + -- position events; this one runs on every one. + if ReputationWatchBar:IsShown() and not MainMenuExpBar:IsShown() then + ExhaustionTick:Hide() + end end -- The tick's tooltip (ref-MainMenuBar.lua ExhaustionToolTipText, the live branches only — @@ -1406,8 +1554,12 @@ flat brass rail that replaces the XP strip at MAX_PLAYER_LEVEL — four 256×7 quarters of UI-MainMenuBar-MaxLevel, riding just above the frame's own 7 px rect (each quarter's BOTTOM sits on the frame's TOP, the ref's own geometry). - Shown/hidden by BenillaMaxLevelBar_Update (decision 1094). The ref's OnShow/ - OnHide call UIParent_ManageFramePositions, which benilla doesn't build + Shown and hidden by ReputationWatchBar_Update (above) and by nothing else — which + is the reference's own arrangement, and why this frame carries NO Scripts. It held + a benilla OnLoad/OnEvent pair while the watch bar was driven by nothing (decision + 1094); those were a transcription of the WATCH BAR's own handlers parked here, and + they have gone back to it. The ref's OnShow/OnHide call + UIParent_ManageFramePositions, which benilla doesn't build (DurabilityFrame.xml's note) — a named cut. --> @@ -1447,10 +1599,6 @@ - - BenillaMaxLevelBar_OnLoad(self) - BenillaMaxLevelBar_OnEvent() - + Geometry quoted from the reference: a 1024x11 frame whose BOTTOM sits on MainMenuBar's TOP + at (0,-3), holding a 1024x8 `ReputationWatchStatusBar` at TOP. TWO sets of end art ride the + status bar and the update swaps between them: the four `ReputationWatchBarTexture*` slices + of `UI-ReputationWatchBar` for the stacked-above-the-XP-bar posture, and the four + `ReputationXPBarTexture*` slices of the dwarf `UI-MainMenuBar-Dwarf` sheet for the + replaces-the-XP-bar posture at max level, where the strip has to look like the bar it took + over from. The numerals live on their own DIALOG-strata overlay child so they draw over + both. --> @@ -1752,17 +1900,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:RegisterEvent("UPDATE_FACTION") + this:RegisterEvent("PLAYER_LEVEL_UP") + this:RegisterEvent("CVAR_UPDATE") + this:RegisterEvent("PLAYER_ENTERING_WORLD") + + + if ( event == "UPDATE_FACTION" and this:IsVisible() ) then + if ( ReputationFrame_Update ) then + ReputationFrame_Update() + end + ReputationWatchBar_Update() + elseif ( event == "PLAYER_LEVEL_UP" ) then + ReputationWatchBar_Update(arg1) + elseif ( event == "CVAR_UPDATE" and arg1 == "statusBarText" ) then + if ( arg2 == "1" ) then + ShowWatchedReputationBarText("lock") + else + HideWatchedReputationBarText("unlock") + end + else + ReputationWatchBar_Update() + end + + + if ( GetCVar("statusBarText") == "1" ) then + ShowWatchedReputationBarText("lock") + end + UIParent_ManageFramePositions() + + UIParent_ManageFramePositions() + ReputationWatchStatusBarText:Show() + + if ( not ReputationWatchBar.textLocked ) then + ReputationWatchStatusBarText:Hide() + end + + diff --git a/crates/benilla-app/assets/ui/CharacterFrame.xml b/crates/benilla-app/assets/ui/CharacterFrame.xml index f55626ea..ae95c8c9 100644 --- a/crates/benilla-app/assets/ui/CharacterFrame.xml +++ b/crates/benilla-app/assets/ui/CharacterFrame.xml @@ -34,8 +34,10 @@ cursor-arc design). Tabs 2-5 (Pet/Reputation/Skills/Honor) were all OMITTED at 1b, the 0138 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`) and **Pet with decision 1057** (`PetPaperDollFrame.xml`), which takes the - real client's own slot 2; Reputation and Honor are still out. Three things here are shared + (`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 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`), @@ -140,6 +142,7 @@ CHARACTER = CHARACTER or "Character" -- :481 SKILLS = SKILLS or "Skills" -- :3477 (decision 0437 phase 4, the Skills tab label) PET = PET or "Pet" -- :3010 (the Pet tab label, decision 1057) + REPUTATION = REPUTATION or "Reputation" -- :3328 (the Reputation tab label) PLAYER_LEVEL = PLAYER_LEVEL or "Level %d %s %s" -- :3075 ARMOR = ARMOR or "Armor" -- :62 ARMOR_COLON = ARMOR_COLON or "Armor:" -- :63 @@ -239,13 +242,14 @@ 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"/"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, which takes the - -- real client's own slot 2 and pushes Skills to 3. Reputation/Honor (the real client's - -- tabs 3/5) are still OMITTED, which is the whole reason our Skills tab renders 3rd where - -- the reference's is 4th. + -- ("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. BENILLA_CHARACTERFRAME_SUBFRAMES = { - "PaperDollFrame", "PetPaperDollFrame", "SkillFrame", + "PaperDollFrame", "PetPaperDollFrame", "ReputationFrame", "SkillFrame", } -- ── The window: toggle / tab dispatch (ref CharacterFrame.lua:1-48) ───────────────────── @@ -317,12 +321,13 @@ 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). 3 tabs ship as of decision 1057 - -- (Character + Pet + Skills); 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, 3) + -- 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 + -- 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) 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 @@ -342,7 +347,11 @@ -- numerals landed (1143). Opening the character sheet reveals your own and your pet's -- numbers for as long as it is open, whatever Status Bar Text says, through the same -- lockShow refcount the hover uses — so a hover inside an open sheet still balances. - -- ShowWatchedReputationBarText stays OMITTED: no reputation watch bar exists. + -- ShowWatchedReputationBarText is LIVE too, since the Reputation tab landed and with it a + -- driven watch bar (ActionBar.xml owns both, its own placement note says why): opening the + -- sheet reveals the watched faction's numbers for as long as it is open, exactly like the + -- five bars above. Guarded on existence like its neighbours — the engine's per-window + -- harnesses load this file without ActionBar.xml. -- UnitPVPName -> plain UnitName (this file's header comment: PVP rank is a named deferral). function CharacterFrame_OnShow() if UpdateMicroButtons then UpdateMicroButtons() end @@ -356,11 +365,14 @@ ShowTextStatusBarText(PetFrameHealthBar) ShowTextStatusBarText(PetFrameManaBar) end + if ShowWatchedReputationBarText then + ShowWatchedReputationBarText() + end end -- ref CharacterFrame_OnHide (l.95-104): everything past the PlaySound is the OnShow list's -- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text calls live too (1146), - -- the reputation one still omitted for the same reason as above. The subframe hide-loop is OUR OWN addition + -- and the reputation one live since the watch bar started being driven. The subframe hide-loop is OUR OWN addition -- (decision 0437 phase 4): PaperDollFrame is a true engine child of this frame (its -- own header comment) so it hides for free on the parent's Hide-cascade, but -- SkillFrame is a SEPARATE top-level file — this engine has no cross-file `parent=` @@ -380,6 +392,9 @@ HideTextStatusBarText(PetFrameHealthBar) HideTextStatusBarText(PetFrameManaBar) end + if HideWatchedReputationBarText then + HideWatchedReputationBarText() + end for _, name in ipairs(BENILLA_CHARACTERFRAME_SUBFRAMES) do local subFrame = getglobal(name) if subFrame then subFrame:Hide() end @@ -1374,22 +1389,25 @@ HideUIPanel(getglobal("CharacterFrame")) - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(REPUTATION_STATUS_AT_WAR, nil, nil, nil, nil, 1) + + GameTooltip:Hide() + + + + + + + if (this.tooltip) then + getglobal(this:GetName() .. "FactionStanding"):SetText(this.tooltip) + end + getglobal(this:GetName() .. "Highlight1"):Show() + getglobal(this:GetName() .. "Highlight2"):Show() + + + getglobal(this:GetName() .. "FactionStanding"):SetText(this.standingText) + if (GetSelectedFaction() ~= this.id) then + getglobal(this:GetName() .. "Highlight1"):Hide() + getglobal(this:GetName() .. "Highlight2"):Hide() + end + + ReputationBar_OnClick() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GameTooltip_AddNewbieTip(FACTION, 1.0, 1.0, 1.0, REPUTATION_FACTION_DESCRIPTION, 1) + GameTooltip:Hide() + + + + + + + + + GameTooltip_AddNewbieTip(STANDING, 1.0, 1.0, 1.0, REPUTATION_STANDING_DESCRIPTION, 1) + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getglobal(this:GetName() .. "Text"):SetText(AT_WAR) + getglobal(this:GetName() .. "Text"):SetVertexColor(RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b) + + BenillaReputationAtWarBox_OnClick(self) + + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(REPUTATION_AT_WAR_DESCRIPTION, nil, nil, nil, nil, 1) + + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + + getglobal(this:GetName() .. "Text"):SetText(MOVE_TO_INACTIVE) + BenillaReputationInactiveBox_OnClick(self) + + + + + + + + + + + + getglobal(this:GetName() .. "Text"):SetText(SHOW_FACTION_ON_MAINSCREEN) + BenillaReputationMainScreenBox_OnClick(self) + + + + + + + ReputationFrame_OnLoad() + ReputationFrame_OnShow() + ReputationFrame_OnEvent(event) + BenillaReputationFrame_OnMouseWheel(arg1) + + + diff --git a/crates/benilla-app/assets/ui/SkillFrame.xml b/crates/benilla-app/assets/ui/SkillFrame.xml index 49b1d822..9bf37701 100644 --- a/crates/benilla-app/assets/ui/SkillFrame.xml +++ b/crates/benilla-app/assets/ui/SkillFrame.xml @@ -8,7 +8,8 @@ `Expand`/`CollapseSkillHeader`/`SetSelectedSkill`/`GetSelectedSkill`, decision 0437's skills seam, `crates/benilla-ui/src/script/skills.rs`, built alongside this file). - A CHILD PAGE of `CharacterFrame` (ref: `parent="CharacterFrame"`, `id="4"`), shown/ + A CHILD PAGE of `CharacterFrame` (ref: `parent="CharacterFrame"`, `id="4"` — ours too, since + the Reputation page landed and took slot 3 back), shown/ hidden by `CharacterFrame_ShowSubFrame` exactly like `PaperDollFrame` — but NOT a physically nested `` the way PaperDollFrame is: PaperDollFrame is declared INSIDE CharacterFrame.xml's own `` tree (a real engine parent/child), while this page is a @@ -467,12 +468,13 @@ - - + + diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index e3bb222f..2dc7db2a 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -69,12 +69,15 @@ row's xOffset 30 + baseY 0 + bottomLeft 45, and MultiBarBottomLeft's XML (0,17) is its row's baseY 17), so the pass now re-derives what the XML asserted. TutorialFrameParent and FramerateLabel still have no frame here and no-op through the ref's `if ( frame )`. - - MultiBarLeft/MultiBarRight (the vertical bars) and ReputationWatchBar don't exist yet: - nil-guarded (the ref reads them bare only because it always creates them). MainMenuExpBar - and MainMenuBarMaxLevelBar DO exist now, so the `reputation` and `maxLevel` flags are - real reads — `reputation` still cannot fire (it needs ReputationWatchBar too) and - `maxLevel` fires exactly when the level-60 rail replaces the XP bar, which is the ref's - own rule. + - Every frame these flags read now exists, so every flag is a real read — the nil guards + stay because the ref reads them bare only because it always creates them, and our + per-window test harnesses load file SUBSETS. `reputation` fires the moment a watched + faction raises ReputationWatchBar over a shown MainMenuExpBar (decision 1258; it could + not fire at all until that page landed, and `reputation_frame_tests` pins the 9px it + moves the bottom stack by). `maxLevel` fires exactly when the level-60 rail replaces the + XP bar — which is also why the two never fire together: at 60 the watch bar REPLACES + MainMenuExpBar rather than stacking above it, so `MainMenuExpBar:IsShown()` is false and + the stack stays put. Both are the ref's own arithmetic. - No IsUserPlaced/IsObjectType/SIMPLE_CHAT legs on the SetPoint: benilla has no user-placed frames and no simple-chat option, so the ref's guarded SetPoint collapses to the plain one. diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index ef94d0a9..0c27a362 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -215,12 +215,27 @@ CharacterFrame.xml # templates and the tab row it raises tab 2 of — all of which only exist once that file has # loaded, so this order is a requirement here, not just tidiness. PetPaperDollFrame.xml +# The Reputation tab: the same separate-top-level-file shape as the two pages around it, seated +# BETWEEN them because that is the real client's tab order (Character 1, Pet 2, Reputation 3, +# Skills 4 — ref CharacterFrame.xml:79-168), and the page's `id=` is that slot. Its hard +# dependencies are all far above: CharacterFrame.xml (the anchor target it is pinned to by name, +# and the tab row it raises tab 3 of), ScrollTemplates.xml (the faux list + its trough template), +# UIPanelTemplates.xml (`UIPanelCloseButton`, the detail popup's X — this page is that shared +# template's first benilla consumer), OptionsFrameTemplates.xml (`OptionsCheckButtonTemplate`, two +# of the three detail boxes), GameTooltip.xml (the column newbie tips) and Fonts.xml. One +# dependency runs the other way and is worth naming: the WATCH BAR half of the reference's +# ReputationFrame lives in ActionBar.xml (entry 94) beside the MainMenuBar frames it drives — that +# file's own comment carries the justification — so `FACTION_BAR_COLORS` and +# `ReputationWatchBar_Update` are already defined when this page paints a row or ticks the +# "Show as Experience Bar" box. +ReputationFrame.xml # The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate # top-level file (SkillFrame.xml's own header comment — this engine has no cross-file # `parent=` attachment, so it's positioned by name via `` instead). Loaded immediately 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. +# "CharacterFrame">` instead). Loaded after CharacterFrame.xml so it paints ON TOP of +# it (later file = higher z) and so `CharacterFrame` already exists for that anchor to +# resolve against — and last of the three sibling pages, because it is the last of the +# three tabs (the real client's 4). SkillFrame.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 diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 0dde5149..936fe63c 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -125,6 +125,7 @@ mod ui_pet_doll; mod ui_pet_stats; mod ui_quest; mod ui_quest_log; +mod ui_reputation; mod ui_saved; mod ui_script; mod ui_session; @@ -509,6 +510,10 @@ pub fn run(build: BuildId) -> AppExit { // The character-window feed (decision 0208): the combat-stats/inventory snapshots + events // the paper doll reads, and the paper-doll booth's yaw mirror. .add_plugins(UiCharPlugin) + // The reputation-pane feed: the player's wire faction slots resolved against Faction.dbc into + // the pane's snapshot, plus the pane's three outbound verbs. Beside the character feed because + // it is the same window's other tab. + .add_plugins(ui_reputation::UiReputationPlugin) // 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) diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 479fad03..2b97bb20 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -457,10 +457,15 @@ fn send_query_time( } } -/// Our player's reputation store (`SMSG_INITIALIZE_FACTIONS`, once at login): `(flags, standing)` -/// per reputation-list slot, indexed by `Faction.dbc`'s `reputationIndex`. The standing excludes the -/// DBC race/class base. Read by the reaction decode (targeting): a reputation faction's NPCs colour -/// by our rank with them, before any faction-template comparison. Empty until the packet lands. +/// Our player's reputation store: `(flags, standing)` per reputation-list slot, indexed by +/// `Faction.dbc`'s `reputationIndex`. The standing excludes the DBC race/class base — consumers add +/// it before ranking. Empty until `SMSG_INITIALIZE_FACTIONS` lands at login; kept current after that +/// by `SMSG_SET_FACTION_STANDING` (which also auto-reveals) and `SMSG_SET_FACTION_VISIBLE`. +/// +/// Two consumers, and they read different halves. The **reaction decode** (targeting) reads the +/// standing: a reputation faction's NPCs colour by our rank with them, before any faction-template +/// comparison. The **reputation pane** ([`crate::ui_reputation`]) reads the flag byte as well — the +/// visible bit decides which rows exist at all, and bit `0x08` marks the pane's headers. #[derive(Resource, Default)] pub(crate) struct Reputations(pub(crate) Vec<(u8, i32)>); @@ -1001,6 +1006,18 @@ pub(crate) enum ClientCommand { /// server's `SetSkill(id, 0, 0)` returns as a `PLAYER_SKILL_INFO` field update, which the /// skills feed re-pushes (the engine never removes the line locally). UnlearnSkill { skill_id: u32 }, + /// Declare or withdraw war on a faction (`CMSG_SET_FACTION_ATWAR`): the reputation pane's + /// crossed-swords box. Addressed by reputation-list slot. No ack — the engine already flipped + /// its own flag copy ([`crate::ui_reputation`]), and vmangos DROPS the request outright while + /// the player is in combat. + SetFactionAtWar { rep_list_id: u32, at_war: bool }, + /// Move a faction into or out of the pane's inactive bucket (`CMSG_SET_FACTION_INACTIVE`). + /// Same slot addressing and same no-ack rule as [`Self::SetFactionAtWar`]. + SetFactionInactive { rep_list_id: u32, inactive: bool }, + /// Watch a faction's bar on the main menu bar (`CMSG_SET_WATCHED_FACTION`). **Signed**, and + /// `-1` — not `0` — is "watch nothing": slot 0 is the Bloodsail Buccaneers. The answer returns + /// as a `PLAYER_FIELD_WATCHED_FACTION_INDEX` descriptor update. + SetWatchedFaction { rep_list_id: i32 }, /// Use a world GameObject (`CMSG_GAMEOBJ_USE`, decision 0236): a full guid naming the /// chest/door/quest-object/lever under the cursor. Sent by the right-click route /// ([`crate::target`]) when the nearest hovered CGObject is a usable GameObject. The server fans diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 6f00dec8..18adeaab 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -611,6 +611,9 @@ pub(super) fn apply_net_updates( SessionEvent::ReputationDelta { standings } => { session::reputation_delta(standings, &mut reputations, &mut quest) } + SessionEvent::ReputationVisible { list_id } => { + session::reputation_visible(list_id, &mut reputations) + } SessionEvent::BindPoint { area } => home_bind.0 = Some(area), SessionEvent::Proficiency { item_class, diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index fd60c349..67fc1dc9 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -363,25 +363,51 @@ pub(super) fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputatio } /// A mid-session standing delta (`SMSG_SET_FACTION_STANDING`): overwrite the changed slots, -/// growing the store for a list id past the login snapshot (flags default 0 — the wire's -/// delta carries none). +/// growing the store for a list id past the login snapshot (flags default 0 — the delta carries +/// none), and **auto-reveal** each one. +/// +/// The auto-reveal is the client's own (wow-5875-re `reputation-panel-law.md`, the `0x124` handler): +/// gaining reputation with a faction makes it visible, unless the slot carries `HIDDEN` — which is +/// exactly what that bit is for, and is why it is not a list gate. The server pushes an +/// `SMSG_SET_FACTION_VISIBLE` for the same slot in most cases (vmangos `SetOneFactionReputation` +/// calls `SetVisible`), so this is usually belt to that braces; it matters when the reveal and the +/// standing arrive in the other order, and it is what the client does regardless. pub(super) fn reputation_delta( standings: Vec<(u32, i32)>, reputations: &mut Reputations, quest: &mut QuestGiver, ) { + use benilla_formats::faction_flags as flag; for (list_id, standing) in standings { let i = list_id as usize; if reputations.0.len() <= i { reputations.0.resize(i + 1, (0, 0)); } reputations.0[i].1 = standing; + if reputations.0[i].0 & flag::HIDDEN == 0 { + reputations.0[i].0 |= flag::VISIBLE; + } } // A standing change is a questgiver-status input (`SatisfyQuestReputation`, and the reaction // gate): the reference sweeps from this handler too (0654). quest.bump_reask(); } +/// A faction became visible (`SMSG_SET_FACTION_VISIBLE`): lift `FACTION_FLAG_VISIBLE` on that slot +/// and nothing else. +/// +/// The server pushes this the first time the player meets a faction, and it carries **no +/// standing** — the slot's standing was already correct and stays untouched. Dropping it is the +/// silent failure it exists to prevent: the pane keys row membership off this bit, so a faction met +/// mid-session would keep accruing reputation the player could never see. +pub(super) fn reputation_visible(list_id: u32, reputations: &mut Reputations) { + let i = list_id as usize; + if reputations.0.len() <= i { + reputations.0.resize(i + 1, (0, 0)); + } + reputations.0[i].0 |= benilla_formats::faction_flags::VISIBLE; +} + /// The keepalive echo (`SMSG_PONG`): match it against the shared ping clock to measure the /// round trip. Stored back on the clock (the next ping's lastRtt field — what the real client /// reports), pushed into the RTT ring the UI's `GetNetStats` averages, and surfaced as the panel's diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index b5cf0f70..73333618 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -763,6 +763,17 @@ fn writer_loop( w.learn_talent(talent_id, rank) } ClientCommand::UnlearnSkill { skill_id } => w.unlearn_skill(skill_id), + ClientCommand::SetFactionAtWar { + rep_list_id, + at_war, + } => w.set_faction_at_war(rep_list_id, at_war), + ClientCommand::SetFactionInactive { + rep_list_id, + inactive, + } => w.set_faction_inactive(rep_list_id, inactive), + ClientCommand::SetWatchedFaction { rep_list_id } => { + w.set_watched_faction(rep_list_id) + } ClientCommand::GameObjUse { guid } => w.gameobj_use(guid), ClientCommand::AreaTrigger { trigger_id } => w.area_trigger(trigger_id), ClientCommand::GameObjectQuery { entry, guid } => w.gameobject_query(entry, guid), diff --git a/crates/benilla-app/src/ui_reputation.rs b/crates/benilla-app/src/ui_reputation.rs new file mode 100644 index 00000000..b523d141 --- /dev/null +++ b/crates/benilla-app/src/ui_reputation.rs @@ -0,0 +1,593 @@ +//! The reputation pane's app half: resolve the player's wire reputation slots against +//! `Faction.dbc`, push the flat snapshot into the VM's reputation seam +//! ([`benilla_ui::script::ReputationState`]), fire `UPDATE_FACTION` on a change, and drain the +//! pane's three outbound verbs back onto the wire. +//! +//! The seam's module doc (`benilla-ui`'s `script::reputation`) is the display law; this file is the +//! **data** law it consumes, and there are exactly three pieces of it: +//! +//! 1. **Which factions are rows.** A `Faction.dbc` row participates iff it has a reputation slot +//! (`reputationIndex >= 0`) **and at least one of its four race/class mask slots fits the +//! player** — the client's own membership gate at `0x4d5555`, where the add call sits inside the +//! accept block of the same loop that picks the base value. Whether the pane then *lists* it is +//! the single flag `VISIBLE`, off until the player first meets them — which is why +//! `SMSG_SET_FACTION_VISIBLE` has to be applied (`net::apply::session::reputation_visible`). +//! +//! **Unlisted factions are pushed anyway, carrying `visible: false`.** They are how the pane's +//! headers learn their names: all five header factions carry `HEADER` (`0x08`) and only one of +//! them carries `VISIBLE` as well, so filtering on visibility would group the tree correctly and +//! then label every header with an empty string. +//! 2. **The standing.** The wire standing EXCLUDES the DBC race/class base value; the total the +//! pane ranks is `base_for(race, class) + wire`. Verified on the server side, which is the side +//! that authors both numbers: vmangos stores `faction.Standing = standing - BaseRep` +//! (`ReputationMgr::SetOneFactionReputation`) and reports +//! `GetBaseReputation(entry) + state->Standing` (`GetReputation`). +//! 3. **The rank window.** [`benilla_formats::reputation_rank`] ranks the total on the 0..=7 scale +//! (shared with the unit-reaction decode, so the pane and the nameplate can never disagree); the +//! absolute floor/ceiling of that rank are [`RANK_BOUNDS`] — byte-for-byte the client's own +//! `.rdata` table at `0x80928c`. The Lua tuple's `standingID` is the rank plus one. +//! +//! And one flag reading that is easy to get wrong and was: **`0x08` is `HEADER`, not +//! "force-invisible"** — see `benilla_formats::faction_flags`. `canToggleAtWar` is +//! `!PEACE_FORCED && standing >= -3000`; the client's toggle enforces more than it reports, but the +//! extra conditions (in combat, and the floor applying only toward peace) are the app's to add if +//! the pane ever needs them. + +use bevy::prelude::*; + +use benilla_ui::script::{FactionEntry, ReputationSend, ReputationState, UiScript}; + +use crate::net::{ClientCommand, NetCommands, ObjectStore, Reputations, SelfPlayer}; +use crate::target::Factions; +use crate::ui_script::UiInput; + +/// The cumulative standing edges of the eight ranks: `RANK_BOUNDS[r]` is rank `r`'s absolute floor +/// and `RANK_BOUNDS[r + 1]` its ceiling. The widths between them are vmangos's `PointsInRank` +/// (36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000) walked up from the −42000 floor, so this +/// table and [`benilla_formats::reputation_rank`]'s thresholds are the same numbers read two ways — +/// `rank_bounds_agree_with_the_rank_function` pins them together. +const RANK_BOUNDS: [i32; 9] = [-42000, -6000, -3000, 0, 3000, 9000, 21000, 42000, 43000]; + +/// Adds the reputation pane's feed and its outbound drain. The bindings live in `benilla-ui`'s +/// `script::reputation`; this supplies their data (and the event) from ECS state. +pub(crate) struct UiReputationPlugin; + +impl Plugin for UiReputationPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + ( + feed_reputation.before(UiInput), + drain_reputation_sends.after(UiInput), + ), + ); + } +} + +/// One faction's row, or `None` when the faction is not in the player's list at all — no reputation +/// slot, no name, or no race/class mask slot that fits them. +/// +/// A faction the pane declines to *draw* is **not** a `None`: that answer rides on +/// [`FactionEntry::visible`], because an unlisted row is still the source of a header's name (see +/// the module doc). +/// +/// Split out as a pure function for the same reason [`crate::ui_char::skills_row`] is: the display +/// predicate is the half a test can drive against the real DBC without standing up a VM. +pub(crate) fn reputation_row( + faction_id: u32, + info: &benilla_formats::FactionInfo, + catalog: &benilla_formats::FactionCatalog, + flags: u8, + wire_standing: i32, + race: u8, + class: u8, +) -> Option { + use benilla_formats::faction_flags as flag; + // The membership gate: no fitting race/class slot, no row at all (`0x4d5555`). + info.slot_for(race, class)?; + let standing = info.base_for(race, class) + wire_standing; + let rank = benilla_formats::reputation_rank(standing); + Some(FactionEntry { + faction_id, + rep_list_id: u32::try_from(info.rep_index).ok()?, + parent_id: info.team, + name: catalog.faction_name(faction_id)?.to_string(), + description: catalog + .faction_description(faction_id) + .unwrap_or_default() + .to_string(), + standing, + standing_id: rank + 1, + bar_min: RANK_BOUNDS[rank as usize], + bar_max: RANK_BOUNDS[rank as usize + 1], + visible: flags & flag::VISIBLE != 0, + is_header: flags & flag::HEADER != 0, + at_war: flags & flag::AT_WAR != 0, + // What GetFactionInfo REPORTS. The toggle itself is stricter (`0x4d5fd0`). + can_toggle_at_war: flags & flag::PEACE_FORCED == 0 && standing >= -3000, + inactive: flags & flag::INACTIVE != 0, + }) +} + +/// Push the snapshot when one of its inputs moves, and fire `UPDATE_FACTION` when the result +/// actually differs — the whole-snapshot-replace seam [`crate::ui_char::feed_skills`] established, +/// with one addition. +/// +/// **The rebuild is gated, unlike the skills feed's.** A snapshot here is 54 rows each carrying a +/// name and a description paragraph, so rebuilding it every frame to discover it is unchanged would +/// allocate ~100 strings a frame forever. Its inputs are exactly four — the wire slots, the +/// catalog, the player's race/class, and the watched index — and all four are cheap to watch, so +/// the expensive build only runs when one of them says something happened. The whole-snapshot +/// equality check stays *behind* that gate: a change to the store that does not change any listed +/// row (a hidden faction's standing ticking up) must not fire the event. +/// +/// The event fires on **every** accepted push including the first, which is what the reference's own +/// login seam relies on: `ReputationWatchBar`'s `OnEvent` initializes the watch bar off +/// `UPDATE_FACTION`, and the faction list lands at every login. +fn feed_reputation( + script: Option>, + self_store: Query<&ObjectStore, With>, + reputations: Res, + factions: Option>, + mut last: Local>, + mut last_inputs: Local)>>, +) { + let Some(mut script) = script else { + return; + }; + let (Ok(store), Some(factions_res)) = (self_store.single(), factions.as_ref()) else { + return; + }; + let catalog = factions_res.catalog(); + let (race, class) = ( + store.0.unit_race().unwrap_or(0), + store.0.unit_class().unwrap_or(0), + ); + let watched = store + .0 + .player_watched_faction() + .and_then(|i| u32::try_from(i).ok()); + + let inputs = (race, class, watched); + let moved = reputations.is_changed() + || factions_res.is_changed() + || last_inputs.as_ref() != Some(&inputs) + || last.is_none(); + if !moved { + return; + } + *last_inputs = Some(inputs); + + let mut entries = Vec::new(); + for (faction_id, info) in catalog.reputation_factions() { + // A slot the wire has not covered reads as `(0, 0)` — no flags, no standing — rather than + // dropping the faction. That is the honest reading (flags of 0 lack VISIBLE, so the row is + // unlisted, which is exactly "you have not met them"), and it keeps the push COMPLETE: + // every header's name is carried by its parent's row, so a short or not-yet-arrived + // standings array must not be able to take a header's label away with it. + let (flags, wire) = usize::try_from(info.rep_index) + .ok() + .and_then(|i| reputations.0.get(i)) + .copied() + .unwrap_or((0, 0)); + if let Some(row) = reputation_row(faction_id, info, catalog, flags, wire, race, class) { + entries.push(row); + } + } + // The engine sorts, so the iteration order of the catalog's map must not reach it as noise: + // sort by the row's own stable identity before the equality check, or a HashMap reshuffle + // would look like a change and fire UPDATE_FACTION every frame. + entries.sort_by_key(|e| e.faction_id); + + let fresh = ReputationState { entries, watched }; + if last.as_ref() == Some(&fresh) { + return; + } + script.set_reputation(fresh.clone()); + *last = Some(fresh); + script.fire_event("UPDATE_FACTION", vec![]); +} + +/// Send the pane's queued verbs. None is acked — the engine already flipped its own copy — so this +/// is pure outbound. +fn drain_reputation_sends(script: Option>, commands: Res) { + let Some(mut script) = script else { + return; + }; + for send in script.take_reputation_sends() { + let cmd = match send { + ReputationSend::AtWar { + rep_list_id, + at_war, + } => ClientCommand::SetFactionAtWar { + rep_list_id, + at_war, + }, + ReputationSend::Inactive { + rep_list_id, + inactive, + } => ClientCommand::SetFactionInactive { + rep_list_id, + inactive, + }, + // The `None` → `-1` translation the wire needs: slot 0 is the Bloodsail Buccaneers, so + // a 0 here would watch them rather than clear the bar. + ReputationSend::Watch(slot) => ClientCommand::SetWatchedFaction { + rep_list_id: slot.map_or(benilla_protocol::messages::WATCHED_FACTION_NONE, |s| { + s as i32 + }), + }, + }; + let _ = commands.0.send(cmd); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// [`RANK_BOUNDS`] and [`benilla_formats::reputation_rank`] are the same `PointsInRank` widths + /// read two ways, so every edge must round-trip: the floor of rank `r` ranks as `r`, and one + /// below it ranks as `r - 1`. Nothing else keeps the pane's bar from disagreeing with the + /// nameplate's colour, since only one of them goes through the rank function. + #[test] + fn rank_bounds_agree_with_the_rank_function() { + for rank in 0u8..=7 { + let (floor, ceiling) = (RANK_BOUNDS[rank as usize], RANK_BOUNDS[rank as usize + 1]); + assert_eq!( + benilla_formats::reputation_rank(floor), + rank, + "rank {rank}'s floor {floor}" + ); + assert_eq!( + benilla_formats::reputation_rank(ceiling - 1), + rank, + "rank {rank}'s top {}", + ceiling - 1 + ); + if rank > 0 { + assert_eq!( + benilla_formats::reputation_rank(floor - 1), + rank - 1, + "one below rank {rank}'s floor" + ); + } + } + // The widths ARE vmangos's PointsInRank, stated rather than implied. + let widths: Vec = RANK_BOUNDS.windows(2).map(|w| w[1] - w[0]).collect(); + assert_eq!(widths, [36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000]); + } + + /// The display predicate and the base-plus-wire sum, on the real `Faction.dbc`. + /// + /// Stormwind (72) is the decisive row, and it exercises the slot pick as well as the sum. Its + /// four DBC base slots are race-gated — `0x4c` (Dwarf/Night Elf/Gnome) → 3100, `0xb2` (the Horde + /// races) → −42000, `0x01` (Human) → 4000, then an all-zero slot — so a human matches the THIRD + /// one and starts at 4000, Friendly. Reading the wire standing alone would show Neutral, and + /// taking slot 0 because it is first in the row would show 3100: both wrong, and both silently. + /// + /// The Horde slot is the same law's other end — an orc reading this faction gets −42000, Hated, + /// which is how a Horde character sees an Alliance city without anything ever being sent. + /// Skips without client data. + #[test] + fn real_stormwind_starts_friendly_for_a_human_and_hidden_factions_never_list() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let cat = benilla_formats::load_faction_catalog(&mut chain).expect("factions"); + use benilla_formats::faction_flags as flag; + // A human warrior (race 1, class 1) who has met Stormwind and gained nothing since. + let sw = cat.reputation_faction(72).expect("Stormwind"); + let row = reputation_row(72, sw, &cat, flag::VISIBLE | flag::PEACE_FORCED, 0, 1, 1) + .expect("Stormwind lists"); + assert_eq!(row.name, "Stormwind"); + assert_eq!( + row.standing, 4000, + "the human-gated base slot, with no wire gain on top" + ); + assert_eq!(row.standing_id, 5, "FACTION_STANDING_LABEL5 = Friendly"); + assert_eq!((row.bar_min, row.bar_max), (3000, 9000)); + assert_eq!(row.parent_id, 469, "grouped under Alliance"); + assert!( + !row.can_toggle_at_war, + "PEACE_FORCED: you cannot go to war with your own people" + ); + // A wire gain rides on top of the base, and carries the rank with it. + let honored = reputation_row(72, sw, &cat, flag::VISIBLE, 5000, 1, 1).expect("lists"); + assert_eq!(honored.standing, 9000); + assert_eq!(honored.standing_id, 6, "Honored begins exactly at 9000"); + assert_eq!((honored.bar_min, honored.bar_max), (9000, 21000)); + + // The same faction and the same empty wire slot, read by an orc: the Horde-gated slot. + let orc = reputation_row(72, sw, &cat, flag::VISIBLE, 0, 2, 1).expect("lists"); + assert_eq!(orc.standing, -42000, "the Horde race mask's base"); + assert_eq!(orc.standing_id, 1, "FACTION_STANDING_LABEL1 = Hated"); + + // Unmet means NOT VISIBLE, not absent — the row still comes back, carrying its name. + let unmet = reputation_row(72, sw, &cat, 0, 0, 1, 1).unwrap(); + assert!(!unmet.visible, "unmet"); + assert_eq!(unmet.name, "Stormwind", "and still carries its name"); + // HIDDEN does NOT hide the row: it suppresses the auto-reveal and the rank-change chat + // notification, and nothing else. Reading it as a list gate — which every emulator's naming + // invites — would silently drop rows the real client draws. + let hidden = reputation_row(72, sw, &cat, flag::VISIBLE | flag::HIDDEN, 0, 1, 1).unwrap(); + assert!(hidden.visible, "HIDDEN is not a list gate"); + assert!(!hidden.is_header, "and it is not the header bit either"); + } + + /// **The membership gate, and the slot rule it shares with the base pick.** + /// + /// A faction no race/class mask slot fits is not in that character's list at all — the client + /// calls its add inside the accept block of the very loop that picks the base (`0x4d5555`). On + /// 1.12's shipped data the gate turns out to exclude **nothing a player can actually roll**, and + /// this pins that: the complete set of excluded pairs is druids of the six races that cannot be + /// druids, which is to say no real character at all. + /// + /// Cenarion Circle is the row that makes the whole rule visible, and the reason it is asserted + /// by value: its first slot takes all eight races but a class mask of `0x1df` that deliberately + /// omits druids, and its second takes Night Elf + Tauren druids at 2000. A rule that stopped at + /// the FIRST match would hand a Night Elf druid slot 0's zero. Skips without client data. + #[test] + fn real_membership_gate_excludes_only_druids_of_the_six_non_druid_races() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let cat = benilla_formats::load_faction_catalog(&mut chain).expect("factions"); + + // 1.12 has no class 6 and no class 10; druid is 11. + const CLASSES: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11]; + const DRUID: u8 = 11; + let mut excluded: Vec<(u32, u8, u8)> = Vec::new(); + for race in 1..=8u8 { + for class in CLASSES { + for (id, info) in cat.reputation_factions() { + if info.slot_for(race, class).is_none() { + excluded.push((id, race, class)); + } + } + } + } + let expected: Vec<(u32, u8, u8)> = [1u8, 2, 3, 5, 7, 8] + .into_iter() + .map(|race| (609, race, DRUID)) + .collect(); + excluded.sort_unstable(); + assert_eq!( + excluded, expected, + "the gate should exclude only Cenarion Circle from druids of the six races that \ + cannot be druids — i.e. nothing a player can roll" + ); + + // The slot rule itself, on that same row: last match wins, and the druid slot is second. + let cc = cat.reputation_faction(609).expect("Cenarion Circle"); + assert_eq!( + cc.slot_for(4, DRUID), + Some(1), + "a Night Elf druid takes slot 1" + ); + assert_eq!(cc.base_for(4, DRUID), 2000); + assert_eq!(cc.slot_for(6, DRUID), Some(1), "so does a Tauren druid"); + assert_eq!(cc.slot_for(1, 1), Some(0), "a human warrior takes slot 0"); + assert_eq!(cc.base_for(1, 1), 0); + } + + /// **The header factions are flagged, mostly invisible, and must be pushed anyway.** + /// + /// This is the bug the `visible` flag exists for, asserted at the site that had it: all five + /// parents carry the `HEADER` bit and four of the five lack `VISIBLE`, so a feed that filtered + /// invisible factions out would hand the engine a correct tree with no header names in it — and + /// the pane would draw the right rows under blank headers. Nothing else catches that: the + /// grouping is right, the counts are right, and only the label is empty. + /// + /// The flags asserted here are the DBC's own defaults for a human warrior, read through the + /// same race/class slot pick the server seeds a fresh character's state from + /// (`ReputationMgr::GetDefaultStateFlags`). Skips without client data. + #[test] + fn real_header_factions_carry_the_header_bit_and_still_reach_the_engine() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let cat = benilla_formats::load_faction_catalog(&mut chain).expect("factions"); + use benilla_formats::faction_flags as flag; + + // Every faction some reputation faction names as its `team` — the pane's headers. + let parents: std::collections::BTreeSet = cat + .reputation_factions() + .map(|(_, f)| f.team) + .filter(|&t| t != 0) + .collect(); + assert_eq!( + parents.iter().copied().collect::>(), + [67, 169, 469, 891, 892], + "Horde, Steamwheedle, Alliance, and the two battleground blocs" + ); + + for id in parents { + let info = cat + .reputation_faction(id) + .unwrap_or_else(|| panic!("parent {id} has its own reputation slot")); + // The DBC default flag byte a human warrior would be seeded with. + let flags = u8::try_from(info.default_flags_for(1, 1)).expect("a flag byte"); + assert!( + flags & flag::HEADER != 0, + "parent {id} must carry the HEADER bit; got {flags:#04x}" + ); + let row = reputation_row(id, info, &cat, flags, 0, 1, 1) + .unwrap_or_else(|| panic!("parent {id} still resolves")); + assert!(row.is_header, "parent {id} is a header row"); + assert!( + !row.name.is_empty(), + "parent {id} reaches the engine WITH its name — that name is the header" + ); + } + // …and only ONE of the five is VISIBLE as well, which is exactly why visibility cannot be + // the test for "is this a header" and why an invisible row still has to be pushed. + let visible_parents = [67u32, 169, 469, 891, 892] + .into_iter() + .filter(|&id| { + let info = cat.reputation_faction(id).unwrap(); + u8::try_from(info.default_flags_for(1, 1)).unwrap() & flag::VISIBLE != 0 + }) + .count(); + assert_eq!(visible_parents, 1, "only Alliance carries VISIBLE as well"); + } +} + +#[cfg(test)] +mod live_wire_tests { + use super::*; + use benilla_ui::script::UiScript; + + /// The `SMSG_INITIALIZE_FACTIONS` flag bytes a **live vmangos** sends a fresh level-1 human + /// warrior, captured 2026-08-13 from this slot's probe character via + /// `cargo run -p benilla-protocol --example faction_probe`. All 64 standings were 0; every slot + /// not listed here had a flag byte of 0 too. + /// + /// Kept as real bytes rather than a hand-built fixture because this is the one input the whole + /// pane is a function of, and a plausible-looking invention would agree with whatever the code + /// did. It also settles, at the wire, the single claim the wow-re carve could only mark + /// INFERRED: that the live server marks exactly the five header factions with `0x08`. + const LIVE_FLAGS: &[(usize, u8)] = &[ + (0, 0x02), + (2, 0x02), + (3, 0x02), + (4, 0x10), + (6, 0x02), + (8, 0x10), + (10, 0x08), + (11, 0x09), + (12, 0x0e), + (14, 0x06), + (15, 0x06), + (16, 0x06), + (17, 0x06), + (18, 0x11), + (19, 0x11), + (20, 0x11), + (21, 0x11), + (22, 0x04), + (23, 0x04), + (24, 0x04), + (25, 0x04), + (26, 0x04), + (29, 0x04), + (30, 0x04), + (31, 0x04), + (32, 0x04), + (33, 0x04), + (34, 0x04), + (35, 0x02), + (38, 0x02), + (39, 0x14), + (40, 0x10), + (41, 0x02), + (43, 0x10), + (44, 0x10), + (45, 0x10), + (46, 0x06), + (47, 0x18), + (48, 0x0e), + (50, 0x10), + (51, 0x10), + (52, 0x02), + (53, 0x10), + (54, 0x02), + ]; + + fn live_store() -> Vec<(u8, i32)> { + let mut slots = vec![(0u8, 0i32); 64]; + for &(i, flags) in LIVE_FLAGS { + slots[i].0 = flags; + } + slots + } + + /// **The whole data law, end to end, on real bytes: what a fresh Alliance character's pane + /// actually says.** + /// + /// Live wire flags → `Faction.dbc` → the feed's rows → the engine's tree → the visible row list + /// `GetNumFactions`/`GetFactionInfo` report. No fixture anywhere in the chain except the capture. + /// + /// The expected answer is checkable against the game itself: a brand-new human warrior's + /// Reputation tab shows the Alliance header and its four city factions, all Neutral-or-better + /// from their DBC bases alone, and nothing else. Exactly five slots carry `VISIBLE`, and the one + /// of them that is also a header (Alliance, `0x09`) must come out as the header rather than as a + /// fifth bar — which is the assertion that would have failed under the emulators' reading of + /// `0x08`. Skips without client data. + #[test] + fn a_fresh_alliance_characters_pane_off_live_wire_bytes() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let cat = benilla_formats::load_faction_catalog(&mut chain).expect("factions"); + let store = live_store(); + + // The five header factions, asserted at the wire — the wow-re carve's one INFERRED claim. + use benilla_formats::faction_flags as flag; + let headers: Vec = LIVE_FLAGS + .iter() + .filter(|(_, f)| f & flag::HEADER != 0) + .map(|&(i, _)| i) + .collect(); + assert_eq!( + headers, + [10, 11, 12, 47, 48], + "the live server marks exactly the five parent factions as headers" + ); + + // The feed, for a human warrior (race 1, class 1). + let mut entries = Vec::new(); + for (id, info) in cat.reputation_factions() { + let (flags, wire) = usize::try_from(info.rep_index) + .ok() + .and_then(|i| store.get(i)) + .copied() + .unwrap_or((0, 0)); + if let Some(row) = reputation_row(id, info, &cat, flags, wire, 1, 1) { + entries.push(row); + } + } + entries.sort_by_key(|e| e.faction_id); + assert_eq!( + entries.len(), + 54, + "every reputation faction reaches the engine" + ); + assert_eq!( + entries.iter().filter(|e| e.visible).count(), + 5, + "and exactly five of them are ones this character has met" + ); + + // The engine's tree. + let mut s = UiScript::new().expect("VM"); + s.set_reputation(ReputationState { + entries, + watched: None, + }); + let rows: Vec = s + .eval( + "local t = {} for i = 1, GetNumFactions() do t[i] = (GetFactionInfo(i)) end return t", + ) + .expect("rows"); + assert_eq!( + rows, + [ + "Alliance", + "Darnassus", + "Gnomeregan Exiles", + "Ironforge", + "Stormwind", + ], + "the Alliance header and its four cities — Alliance is the HEADER, not a fifth bar" + ); + + // …and the bars read their DBC bases, with no wire standing anywhere in the capture. + let (name, sid, min, max, val) = s + .eval::<(String, i64, i64, i64, i64)>( + "local n,_,s,mn,mx,v = GetFactionInfo(5) return n,s,mn,mx,v", + ) + .unwrap(); + assert_eq!(name, "Stormwind"); + assert_eq!(val, 4000, "the human-gated base, with nothing gained yet"); + assert_eq!(sid, 5, "Friendly"); + assert_eq!((min, max), (3000, 9000)); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } +} diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index db39ce3b..c7f9bea3 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -24,9 +24,9 @@ fn shipped_action_bar_drives_end_to_end() { ); if file == "ActionBar.xml" { assert_eq!( - report.frames, 61, + report.frames, 62, "bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \ - + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar and its status bar (2 — hidden, ref ReputationFrame.xml:869)" + + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar with its status bar and its numerals overlay (3 — hidden, ref ReputationFrame.xml:869-994)" ); } } diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index 73725d70..871997da 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -939,17 +939,21 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() { load_xml(&s, "UIParent.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "ScrollTemplates.xml"); + load_xml(&s, "UIPanelTemplates.xml"); + load_xml(&s, "OptionsFrameTemplates.xml"); load_xml(&s, "CharacterFrame.xml"); load_xml(&s, "PetPaperDollFrame.xml"); + load_xml(&s, "ReputationFrame.xml"); load_xml(&s, "SkillFrame.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. The - // reference's numbers are NOT ours — its Skills page is id 4 behind a Reputation tab we have - // not built — so this is the check that makes the divergence loud the day Reputation lands - // between them, instead of silently selecting the wrong tab. - for i in 1..=3 { + // `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 { let id: i64 = s .eval(&format!( "return getglobal(BENILLA_CHARACTERFRAME_SUBFRAMES[{i}]):GetID()" @@ -983,8 +987,8 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() { assert_eq!(selected(&mut s), 1); s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap(); assert!(shown(&mut s, "SkillFrame")); - assert_eq!(selected(&mut s), 3, "the row follows a keybind to Skills"); - assert!(wearing_active_art(&mut s, 3)); + assert_eq!(selected(&mut s), 4, "the row follows a keybind to Skills"); + assert!(wearing_active_art(&mut s, 4)); assert!(!wearing_active_art(&mut s, 1)); // THE REPORT: `C` from the Skills page. The page goes back to Character — and so must the row. @@ -997,7 +1001,7 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() { "the tab row followed the keybind back to Character" ); assert!(wearing_active_art(&mut s, 1)); - assert!(!wearing_active_art(&mut s, 3)); + assert!(!wearing_active_art(&mut s, 4)); // …and the second half of the report — *"then if I click on char tab, it closes the whole // window"* — falls out of the same fix rather than needing its own. `PanelTemplates_SelectTab` diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 94513bef..223cdfd7 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -1052,6 +1052,9 @@ mod character_tests; #[cfg(test)] mod skills_frame_tests; +#[cfg(test)] +mod reputation_frame_tests; + #[cfg(test)] mod pet_paperdoll_tests; diff --git a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs new file mode 100644 index 00000000..3638fdf6 --- /dev/null +++ b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs @@ -0,0 +1,649 @@ +//! The shipped **Reputation tab** (`assets/ui/ReputationFrame.xml`) and the **reputation watch +//! bar** (`assets/ui/ActionBar.xml`) driven end-to-end, engine-only (no Bevy) — the per-window test +//! module the skills/spellbook/bank files already establish. +//! +//! What it pins is the PAINT law, which is the half `benilla-ui`'s own +//! `script::reputation::tests` structurally cannot reach: that module drives the twelve globals and +//! asserts the tuples, and stops at the seam. Everything below is about what the tuples become on +//! screen — which of the 15 fixed row slots a visible index lands in, whether a header slot draws +//! the plus or the minus art, the `FACTION_BAR_COLORS[standingID]` fill, the scroll offset +//! re-binding the slots, the popup's three boxes, and the watch bar's own text and posture. +//! +//! The fixture deliberately mirrors the engine module's own (Alliance over Ironforge + Stormwind, +//! Steamwheedle over Booty Bay, the parentless bucket last) so a failure here reads against a shape +//! that is already pinned one layer down, and the two can be compared line for line. + +use benilla_ui::script::{FactionEntry, ReputationState, UiScript, UnitState}; + +/// Load one shipped `assets/ui/` into `s`, panicking on any loader error (the +/// skills/character 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 + ); +} + +/// An ordinary bar row: visible, not a header, `standing_id` 5 ("Friendly") sitting 1000 into a +/// 6000-wide rank window. The same numbers `benilla-ui`'s own fixture uses. +fn entry(faction_id: u32, rep_list_id: u32, parent_id: u32, name: &str) -> FactionEntry { + FactionEntry { + faction_id, + rep_list_id, + parent_id, + name: name.into(), + description: format!("About the {name}."), + standing: 4000, + standing_id: 5, + bar_min: 3000, + bar_max: 9000, + visible: true, + is_header: false, + at_war: false, + can_toggle_at_war: true, + inactive: false, + } +} + +/// A header row as the wire really delivers one: flag `0x08` set and NOT visible (the Steamwheedle +/// Cartel's actual byte). Its own visibility never gates its group. +fn header(faction_id: u32, rep_list_id: u32, name: &str) -> FactionEntry { + FactionEntry { + visible: false, + is_header: true, + ..entry(faction_id, rep_list_id, 0, name) + } +} + +/// The engine module's own fixture, pushed out of order so the engine's sort is what shows. The +/// visible rows it produces, in order: +/// +/// | slot | row | +/// |------|-----| +/// | 1 | header `Alliance` | +/// | 2 | `Ironforge` | +/// | 3 | `Stormwind` | +/// | 4 | header `Steamwheedle Cartel` | +/// | 5 | `Booty Bay` | +/// | 6 | header `Other` | +/// | 7 | `Argent Dawn` | +/// | 8 | `Bloodsail Buccaneers` | +fn state() -> ReputationState { + ReputationState { + entries: vec![ + entry(72, 19, 469, "Stormwind"), + entry(529, 13, 0, "Argent Dawn"), + header(469, 11, "Alliance"), + entry(21, 1, 169, "Booty Bay"), + entry(47, 20, 469, "Ironforge"), + header(169, 10, "Steamwheedle Cartel"), + entry(87, 0, 0, "Bloodsail Buccaneers"), + ], + watched: None, + } +} + +/// The manifest prefix this page really needs, in manifest order: the fonts, the panel manager, the +/// managed-position pass, the tooltip, the numerals machinery + Cooldown + ActionBar (the watch bar +/// and every frame it drives live there), the two scroll/shared-widget kits the page's list and +/// popup inherit from, then the window and the page. +fn load_page(s: &UiScript) { + for file in [ + "Fonts.xml", + "UiPanels.xml", + "UIParent.xml", + "GameTooltip.xml", + "TextStatusBar.xml", + "Cooldown.xml", + "ActionBar.xml", + "ScrollTemplates.xml", + "UIPanelTemplates.xml", + "OptionsFrameTemplates.xml", + "CharacterFrame.xml", + "ReputationFrame.xml", + ] { + load_xml(s, file); + } +} + +/// The Reputation page, open on its tab, with [`state`] pushed and a level-40 player behind it. +fn shown_reputation_page() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_page(&s); + s.set_unit( + "player", + Some(UnitState { + exists: true, + level: 40, + ..UnitState::default() + }), + ); + s.set_reputation(state()); + s.run(r#"ToggleCharacter("ReputationFrame")"#).unwrap(); + s.resolve(); + s +} + +/// Click the centre of a named frame through the real pointer pipeline, the way the app does — so +/// a frame silently eating the row's clicks fails here rather than on the director's screen. +fn click_center(s: &mut UiScript, name: &str) { + s.resolve(); + let (x, y) = s + .eval::<(f32, f32)>(&format!( + "return ({name}:GetLeft() + {name}:GetRight()) / 2, \ + ({name}:GetTop() + {name}:GetBottom()) / 2" + )) + .unwrap(); + s.mouse_move(x, y); + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); +} + +fn text_of(s: &mut UiScript, expr: &str) -> String { + s.eval::(&format!("return {expr}:GetText() or \"\"")) + .unwrap() +} + +fn shown(s: &mut UiScript, name: &str) -> bool { + s.eval::(&format!("return {name}:IsShown() and true or false")) + .unwrap() +} + +/// **The page is the character window's tab 3, and it comes up on it.** The whole renumber rides on +/// this: `ToggleCharacter` selects the row from the page's own `id=` +/// (ref `CharacterFrame.lua:11`), so a page seated at the wrong id shows the right pane under the +/// wrong tab. Skills moved to 4 in the same breath and is asserted here for the same reason. +#[test] +fn the_reputation_page_opens_on_the_windows_third_tab() { + let mut s = shown_reputation_page(); + assert!( + s.eval::("return ReputationFrame:IsVisible()") + .unwrap(), + "the Reputation page is up" + ); + assert_eq!( + s.eval::("return PanelTemplates_GetSelectedTab(CharacterFrame)") + .unwrap(), + 3, + "and the tab row selects 3 — the reference's own slot for Reputation" + ); + assert_eq!( + s.eval::("return ReputationFrame:GetID()").unwrap(), + 3, + "the id IS the tab slot; BENILLA_CHARACTERFRAME_SUBFRAMES[3] must name this page" + ); + assert_eq!( + s.eval::("return BENILLA_CHARACTERFRAME_SUBFRAMES[3]") + .unwrap(), + "ReputationFrame" + ); + assert_eq!( + s.eval::("return BENILLA_CHARACTERFRAME_SUBFRAMES[4]") + .unwrap(), + "SkillFrame", + "and Skills moved to the reference's 4 with it" + ); + + // Eight rows in fifteen slots: the tail goes dark on BOTH twins, and a list that fits raises + // neither the scroll bar nor its trough (the kit shows and hides the pair together). + assert_eq!(s.eval::("return GetNumFactions()").unwrap(), 8); + assert!( + !shown(&mut s, "ReputationBar9"), + "slot 9 has nothing to hold" + ); + assert!(!shown(&mut s, "ReputationHeader9"), "neither twin shows"); + assert!(!shown(&mut s, "ReputationListScrollFrameScrollBar")); + assert!(!shown(&mut s, "ReputationListScrollFrameScrollBarTrough")); + + // Switching away hides it again through the same one-page-at-a-time switch. + s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap(); + assert!(!shown(&mut s, "ReputationFrame"), "one page at a time"); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// **A header slot draws the fold icon its state calls for, and a click folds the group.** +/// +/// Two laws in one: the minus/plus art is chosen from `isCollapsed` (ref `ReputationFrame.lua` +/// l.54-58), and the click has to REPAINT — `CollapseFactionHeader` is a pure model write in this +/// engine where the client's C binding raises `UPDATE_FACTION`, so a handler that only calls the +/// global leaves the folded rows on screen (ReputationFrame.xml's deviation 3). +#[test] +fn a_header_row_paints_its_fold_icon_and_folds_on_click() { + let mut s = shown_reputation_page(); + + assert_eq!(text_of(&mut s, "ReputationHeader1"), "Alliance"); + assert!(shown(&mut s, "ReputationHeader1"), "slot 1 is a header"); + assert!(!shown(&mut s, "ReputationBar1"), "so its bar is down"); + let icon = |s: &mut UiScript| { + s.eval::("return ReputationHeader1:GetNormalTexture():GetTexture() or \"\"") + .unwrap() + }; + assert!( + icon(&mut s).contains("UI-MinusButton-Up"), + "an expanded header wears the MINUS: {}", + icon(&mut s) + ); + assert_eq!( + text_of(&mut s, "ReputationBar2FactionName"), + "Ironforge", + "its two children follow it" + ); + + click_center(&mut s, "ReputationHeader1"); + + assert!( + icon(&mut s).contains("UI-PlusButton-Up"), + "a collapsed header wears the PLUS: {}", + icon(&mut s) + ); + assert_eq!( + text_of(&mut s, "ReputationHeader2"), + "Steamwheedle Cartel", + "and slot 2 has re-bound to the next group — the repaint the click owes" + ); + assert!( + !shown(&mut s, "ReputationBar2"), + "Ironforge is not on screen at all any more" + ); + assert_eq!( + s.eval::("return GetNumFactions()").unwrap(), + 6, + "the two folded children leave the visible list" + ); + + // …and unfolding puts them straight back, through the same handler. + click_center(&mut s, "ReputationHeader1"); + assert_eq!(text_of(&mut s, "ReputationBar2FactionName"), "Ironforge"); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// **A bar row paints its name, its standing word and its `FACTION_BAR_COLORS[standingID]` fill**, +/// over a bar whose range is the rank window NORMALIZED — `barMax - barMin` and `barValue - barMin` +/// (ref `ReputationFrame.lua` l.79-82), which is the entire reason the engine reports those three +/// absolute. +#[test] +fn a_bar_row_paints_name_standing_and_the_faction_bar_colour() { + let mut s = shown_reputation_page(); + + assert_eq!(text_of(&mut s, "ReputationBar2FactionName"), "Ironforge"); + assert_eq!( + text_of(&mut s, "ReputationBar2FactionStanding"), + "Friendly", + "standingID 5 is FACTION_STANDING_LABEL5" + ); + assert_eq!( + s.eval::<(f64, f64)>("return ReputationBar2:GetMinMaxValues()") + .unwrap(), + (0.0, 6000.0), + "the 3000..9000 rank window, normalized to 0..6000" + ); + assert_eq!( + s.eval::("return ReputationBar2:GetValue()").unwrap(), + 1000.0, + "and 4000 standing is 1000 into it" + ); + let (r, g, b) = s + .eval::<(f32, f32, f32)>("return ReputationBar2:GetStatusBarColor()") + .unwrap(); + assert_eq!( + (r, g, b), + (0.0, 0.6, 0.1), + "FACTION_BAR_COLORS[5] — the green every friendly-and-better rank shares" + ); + + // The hover swaps the standing word for the raw progress and lights the glow pair; leaving puts + // the word back (ref ReputationFrame.xml l.189-203). + assert!( + !shown(&mut s, "ReputationBar2Highlight1"), + "glow starts down" + ); + let (x, y) = s + .eval::<(f32, f32)>( + "return (ReputationBar2:GetLeft() + ReputationBar2:GetRight()) / 2, \ + (ReputationBar2:GetTop() + ReputationBar2:GetBottom()) / 2", + ) + .unwrap(); + s.mouse_move(x, y); + assert_eq!( + text_of(&mut s, "ReputationBar2FactionStanding"), + "|cffffffff 1000 / 6000|r", + "hovering shows the numbers, in the reference's own colour-coded form" + ); + assert!(shown(&mut s, "ReputationBar2Highlight1")); + assert!(shown(&mut s, "ReputationBar2Highlight2")); + s.mouse_move(0.0, 0.0); + assert_eq!( + text_of(&mut s, "ReputationBar2FactionStanding"), + "Friendly", + "and leaving restores the word" + ); + assert!( + !shown(&mut s, "ReputationBar2Highlight1"), + "the glow goes with it — this row is not the selected one" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// **The 15 row slots are a window onto the list, and the scroll offset is what moves it.** The +/// faux kit re-binds fixed slots to a moving data offset rather than scrolling anything (decision +/// 0251), so this is the assertion that the slots really are re-bound and not just clipped. +#[test] +fn the_scroll_offset_rebinds_the_fixed_row_slots() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_page(&s); + s.set_unit("player", Some(UnitState::default())); + + // One header over twenty children: 21 visible rows against 15 slots. + let mut entries = vec![header(469, 11, "Alliance")]; + for i in 1..=20u32 { + entries.push(entry(1000 + i, i, 469, &format!("Faction {i:02}"))); + } + s.set_reputation(ReputationState { + entries, + watched: None, + }); + s.run(r#"ToggleCharacter("ReputationFrame")"#).unwrap(); + s.resolve(); + + assert_eq!(s.eval::("return GetNumFactions()").unwrap(), 21); + assert_eq!(text_of(&mut s, "ReputationHeader1"), "Alliance"); + assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), ""); + assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 14"); + assert!( + shown(&mut s, "ReputationListScrollFrameScrollBar"), + "21 rows in 15 slots raises the scroll bar" + ); + assert!( + shown(&mut s, "ReputationListScrollFrameScrollBarTrough"), + "and the trough it rides in comes up with it (the kit shows the pair together)" + ); + + // **The trough fits this window to the pixel, and that is not a coincidence.** + // `BenillaScrollTrough_Seat`'s 21-above / 20-below / 8-left hang was DERIVED from + // ReputationFrame's own reference anchors (ScrollTemplates.xml cites ref l.573-599 against + // ref-UIPanelTemplates.xml l.166-181), so the seated trough has to land back on the reference's + // own numbers here: TOPRIGHT + (-2, +5), BOTTOMRIGHT + (+29, -4), 31 wide. Miss it by 4 and the + // arrow buttons ride out of their sockets onto the caps, which is what B224 reported. + let d = |s: &mut UiScript, expr: &str| s.eval::(&format!("return {expr}")).unwrap(); + let sf_right = d(&mut s, "ReputationListScrollFrame:GetRight()"); + let sf_top = d(&mut s, "ReputationListScrollFrame:GetTop()"); + let sf_bottom = d(&mut s, "ReputationListScrollFrame:GetBottom()"); + let t = "ReputationListScrollFrameScrollBarTrough"; + assert_eq!(d(&mut s, &format!("{t}:GetLeft()")), sf_right - 2.0); + assert_eq!(d(&mut s, &format!("{t}:GetRight()")), sf_right + 29.0); + assert_eq!(d(&mut s, &format!("{t}:GetTop()")), sf_top + 5.0); + assert_eq!(d(&mut s, &format!("{t}:GetBottom()")), sf_bottom - 4.0); + assert_eq!(d(&mut s, &format!("{t}:GetWidth()")), 31.0); + + // Scroll three rows down: every slot re-binds, and slot 1 stops being a header. + s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 3) ReputationFrame_Update()") + .unwrap(); + assert!( + !shown(&mut s, "ReputationHeader1"), + "slot 1 now holds a bar, so its header twin is down" + ); + assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "Faction 03"); + assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 17"); + + // Past the end the kit CLAMPS rather than running off it: 21 rows in 15 slots bottom out at + // offset 6, so asking for 9 lands on that same last page with the final row in slot 15. + s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 9) ReputationFrame_Update()") + .unwrap(); + assert_eq!( + s.eval::("return FauxScrollFrame_GetOffset(ReputationListScrollFrame)") + .unwrap(), + 6, + "clamped to numItems - numToDisplay, so no slot can bind past the end" + ); + assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "Faction 06"); + assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 20"); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// **Clicking a bar opens the detail popup on that faction**, with its name, its description and +/// its three switches in the states the row's own flags call for — including the disabled, greyed +/// At War box a peace-forced faction gets (ref `ReputationFrame.lua` l.115-121). Clicking the same +/// bar again closes it, which is the reference's own toggle (l.152-153). +#[test] +fn clicking_a_bar_opens_the_detail_popup_on_that_faction() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_page(&s); + s.set_unit("player", Some(UnitState::default())); + // Ironforge at war and peace-forced — the two flags the popup reads back off the row. + let mut st = state(); + for e in &mut st.entries { + if e.name == "Ironforge" { + e.at_war = true; + e.can_toggle_at_war = false; + } + } + s.set_reputation(st); + s.run(r#"ToggleCharacter("ReputationFrame")"#).unwrap(); + s.resolve(); + + assert!(!shown(&mut s, "ReputationDetailFrame"), "closed to start"); + assert!( + shown(&mut s, "ReputationBar2AtWarCheck"), + "the at-war pennant flies on the row itself" + ); + + click_center(&mut s, "ReputationBar2"); + + assert!(shown(&mut s, "ReputationDetailFrame"), "the popup opened"); + assert_eq!( + s.eval::("return GetSelectedFaction()").unwrap(), + 2, + "on Ironforge's visible row" + ); + assert_eq!(text_of(&mut s, "ReputationDetailFactionName"), "Ironforge"); + assert_eq!( + text_of(&mut s, "ReputationDetailFactionDescription"), + "About the Ironforge." + ); + assert!( + shown(&mut s, "ReputationBar2Highlight1"), + "and the selected row keeps its glow with the mouse away" + ); + + let checked = |s: &mut UiScript, box_name: &str| { + s.eval::(&format!("return {box_name}:GetChecked() and true or false")) + .unwrap() + }; + assert!( + checked(&mut s, "ReputationDetailAtWarCheckBox"), + "at war, so the box is ticked" + ); + assert!( + !s.eval::("return ReputationDetailAtWarCheckBox:IsEnabled() and true or false") + .unwrap(), + "peace-forced, so the box is dead" + ); + let (r, g, b) = s + .eval::<(f32, f32, f32)>("return ReputationDetailAtWarCheckBoxText:GetTextColor()") + .unwrap(); + assert_eq!( + (r, g, b), + (0.5, 0.5, 0.5), + "and its label greys with it (GRAY_FONT_COLOR)" + ); + assert!(!checked(&mut s, "ReputationDetailInactiveCheckBox")); + assert!(!checked(&mut s, "ReputationDetailMainScreenCheckBox")); + + // The reference's toggle: the same bar again closes the popup rather than re-opening it. + click_center(&mut s, "ReputationBar2"); + assert!(!shown(&mut s, "ReputationDetailFrame"), "clicked shut"); + + // **The whole ROW is the click target, not just the 137px bar.** The template's + // `HitRectInsets left="-126"` (ref `ReputationFrame.xml` l.61-63) is a NEGATIVE inset, which + // WIDENS the mouse rect back over the faction name — so clicking "Ironforge" selects it exactly + // as clicking its bar does. Driven from the name's own centre, which is outside the bar's rect. + let name_x = s + .eval::( + "return (ReputationBar2FactionName:GetLeft() + ReputationBar2FactionName:GetRight()) / 2", + ) + .unwrap(); + let bar_left = s.eval::("return ReputationBar2:GetLeft()").unwrap(); + assert!( + name_x < bar_left, + "the name really does sit left of the bar ({name_x} < {bar_left})" + ); + let y = s + .eval::("return (ReputationBar2:GetTop() + ReputationBar2:GetBottom()) / 2") + .unwrap(); + assert_eq!( + s.hit_test_name(name_x, y).as_deref(), + Some("ReputationBar2"), + "the widened hit rect reaches the name" + ); + s.mouse_move(name_x, y); + s.mouse_button(name_x, y, "LeftButton", true); + s.mouse_button(name_x, y, "LeftButton", false); + assert!( + shown(&mut s, "ReputationDetailFrame"), + "and a click there opens the popup, the same as a click on the bar" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// **The watch bar shows what is being watched, and swaps posture at max level.** +/// +/// Below max level it STACKS above the XP bar, 8px tall, wearing the `UI-ReputationWatchBar` end +/// art; at max level it REPLACES the XP bar, 13px tall on MainMenuBar's own top, wearing the dwarf +/// art instead so the swap reads as the same bar (ref `ReputationFrame.lua` l.184-230). The rested +/// tick goes with the XP bar it rode, which is one of the two guards that were cut from +/// `ExhaustionTick_Update` as unreachable while this bar could never show. +#[test] +fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_page(&s); + s.set_unit( + "player", + Some(UnitState { + exists: true, + level: 40, + ..UnitState::default() + }), + ); + assert!( + !shown(&mut s, "ReputationWatchBar"), + "nothing watched: the bar stays down, as it has since it was a stub" + ); + + // Ironforge is rep slot 20; watching it is a server field, so it rides the push. + let mut st = state(); + st.watched = Some(20); + s.set_reputation(st); + s.fire_event("UPDATE_FACTION", vec![]); + s.resolve(); + + assert!(shown(&mut s, "ReputationWatchBar"), "the bar came up"); + assert_eq!( + text_of(&mut s, "ReputationWatchStatusBarText"), + "Ironforge 1000 / 6000", + "name plus the NORMALIZED progress, the reference's own string" + ); + let (r, g, b) = s + .eval::<(f32, f32, f32)>("return ReputationWatchStatusBar:GetStatusBarColor()") + .unwrap(); + assert_eq!((r, g, b), (0.0, 0.6, 0.1), "FACTION_BAR_COLORS[5] again"); + assert_eq!( + s.eval::<(f64, f64)>("return ReputationWatchStatusBar:GetMinMaxValues()") + .unwrap(), + (0.0, 6000.0) + ); + assert!( + shown(&mut s, "MainMenuExpBar"), + "below 60 the strip STACKS on the XP bar rather than replacing it" + ); + assert!( + shown(&mut s, "ReputationWatchBarTexture0"), + "rep end art up" + ); + assert!(!shown(&mut s, "ReputationXPBarTexture0"), "dwarf art down"); + assert_eq!( + s.eval::("return ReputationWatchStatusBar:GetHeight()") + .unwrap(), + 8.0 + ); + + // **`UIParent_ManageFramePositions`'s `reputation` branch fires for the first time.** It has + // been live in UIParent.xml since decision 0272 and gated on + // `ReputationWatchBar:IsShown() and MainMenuExpBar:IsShown()`, which that file's own header + // notes "still cannot fire" — it can now, and this is the check that it does. `PETACTIONBAR_YPOS` + // is the cheapest witness: an `isVar` row the pass writes as a plain global (baseY 97, plus the + // row's own `reputation = 9`), so it needs no frame loaded to read back. + assert_eq!( + s.eval::("return PETACTIONBAR_YPOS").unwrap(), + 106.0, + "the bottom stack lifts by the row's 9 while the stacked watch bar is up" + ); + s.run("ReputationWatchBar:Hide() UIParent_ManageFramePositions()") + .unwrap(); + assert_eq!( + s.eval::("return PETACTIONBAR_YPOS").unwrap(), + 97.0, + "and drops back to the base when it goes" + ); + s.run("ReputationWatchBar:Show() UIParent_ManageFramePositions()") + .unwrap(); + + // Ding to 60: the reputation strip takes the XP bar's place, art and all. + s.set_unit( + "player", + Some(UnitState { + exists: true, + level: 60, + ..UnitState::default() + }), + ); + s.fire_event( + "PLAYER_LEVEL_UP", + vec![benilla_ui::script::ScriptValue::Int(60)], + ); + s.resolve(); + + assert!(shown(&mut s, "ReputationWatchBar"), "still watching"); + assert!( + !shown(&mut s, "MainMenuExpBar"), + "at 60 the strip REPLACES the XP bar" + ); + assert!( + !shown(&mut s, "MainMenuBarMaxLevelBar"), + "and the brass rail stays down — the watched bar is what fills that space" + ); + assert!( + !shown(&mut s, "ExhaustionTick"), + "no XP strip, no rested tick" + ); + assert!(shown(&mut s, "ReputationXPBarTexture0"), "dwarf art up"); + assert!(!shown(&mut s, "ReputationWatchBarTexture0"), "rep art down"); + assert_eq!( + s.eval::("return ReputationWatchStatusBar:GetHeight()") + .unwrap(), + 13.0 + ); + + // Stop watching: at 60 with nothing watched the brass rail is what takes the XP bar's place. + let mut st = state(); + st.watched = None; + s.set_reputation(st); + s.fire_event("UPDATE_FACTION", vec![]); + s.resolve(); + assert!(!shown(&mut s, "ReputationWatchBar")); + assert!(shown(&mut s, "MainMenuBarMaxLevelBar"), "the rail is back"); + assert!(!shown(&mut s, "MainMenuExpBar")); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-formats/src/factions.rs b/crates/benilla-formats/src/factions.rs index 9b1faa2c..3d668d38 100644 --- a/crates/benilla-formats/src/factions.rs +++ b/crates/benilla-formats/src/factions.rs @@ -12,11 +12,17 @@ //! friendGroupMask@4, enemyGroupMask@5, enemies[4]@6..9, friends[4]@10..13 (both id lists //! 0-terminated). //! -//! Also here: the **reputation identity** side of the decode — Faction.dbc's reputation slot + -//! race/class-gated base values ([`FactionInfo`]) and the standing→rank thresholds -//! ([`reputation_rank`]). The client checks `FactionHasReputation` *before* the comparator -//! (`0x606530`): a reputation faction's NPCs colour by the player's rank, not by templates. The -//! live standings are session state (`SMSG_INITIALIZE_FACTIONS`) and live with the net layer. +//! Also here: the **reputation identity** side of the decode — Faction.dbc's reputation slot, +//! parent pointer and race/class-gated base values ([`FactionInfo`]), the standing→rank thresholds +//! ([`reputation_rank`]), and the names of the wire flag byte's bits ([`faction_flags`]). The client +//! checks `FactionHasReputation` *before* the comparator (`0x606530`): a reputation faction's NPCs +//! colour by the player's rank, not by templates. The live standings are session state +//! (`SMSG_INITIALIZE_FACTIONS`) and live with the net layer. +//! +//! Two consumers share that identity now — the reaction decode above, and the **reputation pane** +//! (decision 1258, whose display law is `benilla-ui`'s `script::reputation`). Where both need the +//! same number they take it from the same function here, so a rank the pane draws and a colour the +//! nameplate paints can never disagree. //! //! Deliberately **not** here: the rest of the real `UnitReaction 0x6061e0` orchestration — //! same-object/group/guild shortcuts, charm resolution, PvP-flag + sanctuary overrides. Those need @@ -119,6 +125,16 @@ pub struct FactionInfo { /// `reputationIndex` — the slot into the `SMSG_INITIALIZE_FACTIONS` standings array; `-1` = this /// faction has no reputation (reaction falls through to the template comparator). pub rep_index: i32, + /// `Faction.dbc` field 18 — `team` in mangos, `m_parentFactionID` in the later builds' WDB + /// names. The **parent pointer**: Stormwind/Ironforge/Darnassus/Gnomeregan Exiles all carry 469 + /// (Alliance), the four Steamwheedle towns carry 169, the six battleground factions carry + /// 891/892, and `0` means no parent. It is the only tree edge 1.12's `Faction.dbc` has. + /// + /// It is the reputation pane's **grouping key**, but *not* what makes a row a header — that is + /// the wire flag byte's [`faction_flags::HEADER`] bit. The distinction matters because the + /// client synthesizes a header for a group key that has no header row yet, and because two of + /// the pane's headers (`0` "Other" and `-1` "Inactive") name no faction at all. + pub team: u32, race_masks: [u32; 4], class_masks: [u32; 4], base: [i32; 4], @@ -128,20 +144,55 @@ pub struct FactionInfo { } impl FactionInfo { - /// The base reputation for a player of `race`/`class` (1-based ids): the first of the four - /// race/class-gated slots that fits (a zero mask is a wildcard) — vmangos `GetIndexFitTo`, - /// mirroring the client's own base pick. `0` if none fits. - pub fn base_for(&self, race: u8, class: u8) -> i32 { + /// The race/class-gated slot the **client** picks for a player of `race`/`class` (1-based ids), + /// or `None` if no slot fits. + /// + /// `None` is not merely "no base value": it is the reputation pane's **membership gate** — the + /// client's list build calls its add inside this loop's accept block (`0x4d5555`), so a faction + /// no slot fits is not in the player's list at all. + /// + /// Byte-verified at `0x4d5500`–`0x4d5564` (wow-5875-re + /// `system/ui/scratch/reputation-panel-law.md`), and it differs from vmangos's `GetIndexFitTo` + /// in **two** ways that this project got wrong first time by reading the emulator instead: + /// + /// 1. A slot with **both** masks zero is a **reject**, not a double wildcard (`0x4d550b`). Only + /// one of the two may be zero-as-wildcard. + /// 2. The loop has **no early exit** — the **last** matching slot wins, not the first. + /// + /// On the shipped 1.12 `Faction.dbc` the distinction is invisible: over all 190 rows × 72 valid + /// race/class pairs, zero of the 13,680 evaluations disagree with the first-match rule (no row + /// has a both-zero slot with a non-zero base, and none has two matching slots with different + /// bases). It is written the client's way regardless, because a rule that is right only for the + /// data we happen to ship is not a rule — a patched DBC would diverge silently. + pub fn slot_for(&self, race: u8, class: u8) -> Option { let race_mask = 1u32 << (race.max(1) - 1); let class_mask = 1u32 << (class.max(1) - 1); - for i in 0..4 { - if (self.race_masks[i] == 0 || self.race_masks[i] & race_mask != 0) - && (self.class_masks[i] == 0 || self.class_masks[i] & class_mask != 0) - { - return self.base[i]; - } - } - 0 + (0..4).rfind(|&i| { + let (races, classes) = (self.race_masks[i], self.class_masks[i]); + (races != 0 || classes != 0) + && (races == 0 || races & race_mask != 0) + && (classes == 0 || classes & class_mask != 0) + }) + } + + /// The base reputation for a player of `race`/`class` — the [`Self::slot_for`] slot's value, + /// `0` if none fits. This is the term the wire standing EXCLUDES: the total a rank is taken + /// from is this plus the wire value (byte-verified at `0x4d6370`: `wire + base`, and each + /// addend has exactly one writer image-wide). + pub fn base_for(&self, race: u8, class: u8) -> i32 { + self.slot_for(race, class).map_or(0, |i| self.base[i]) + } + + /// The DBC's **default** reputation flag byte for a player of `race`/`class` — the same slot + /// pick as [`Self::base_for`], reading the `ReputationFlags` column instead of the base + /// (vmangos `ReputationMgr::GetDefaultStateFlags`). `0` if no slot fits. + /// + /// **The client never reads this column** (a byte-verified negative — the flag byte it displays + /// from is entirely the wire's). It is the *server's* seed for a fresh character's per-slot + /// state, and it is read here only to say what a faction is by default: notably that exactly + /// the pane's five header factions carry [`faction_flags::HEADER`]. + pub fn default_flags_for(&self, race: u8, class: u8) -> u32 { + self.slot_for(race, class).map_or(0, |i| self.flags[i]) } /// Whether the unit tooltip shows this faction's name to a player of `race`/`class` — the @@ -186,6 +237,49 @@ pub fn reputation_rank(total_standing: i32) -> u8 { } } +/// The per-slot reputation flag byte the server keeps for each of the player's factions and sends +/// as the first field of every `SMSG_INITIALIZE_FACTIONS` entry. +/// +/// **Named from the CLIENT's own reads, not from the emulators' enum** (wow-5875-re +/// `system/ui/scratch/reputation-panel-law.md`, byte-verified: the thirteen accesses to the store at +/// `0xb73294` are the complete image-wide population). That distinction is not pedantry — the two +/// disagree on bit `0x08`, which every emulator calls `INVISIBLE_FORCED` and the client tests as +/// **HEADER** (`entry.isHeader = (flags >> 3) & 1`, `0x4d5acb`). Believing the emulator's name gets +/// the *right rows* on screen for the wrong reason (the five factions carrying it are exactly the +/// pane's headers, so "hide these" and "these are headers" pick the same set) and then gets the +/// header machinery wrong everywhere it matters. +/// +/// The same bit layout appears in `Faction.dbc`'s four `ReputationFlags` columns, which is where the +/// **server** seeds a slot's initial byte from (`ReputationMgr::GetDefaultStateFlags`). The client +/// never reads that column — the byte it displays from is entirely the wire's. +/// +/// Two of these the client also *writes*, optimistically, because neither send is acked: [`AT_WAR`] +/// and [`INACTIVE`]. +pub mod faction_flags { + /// The faction is listed in the reputation pane at all. Off until the player first meets them; + /// the server lifts it and pushes `SMSG_SET_FACTION_VISIBLE`. The **only** flag gating list + /// membership. + pub const VISIBLE: u8 = 0x01; + /// The player has declared war on this faction. + pub const AT_WAR: u8 = 0x02; + /// Suppresses the auto-reveal on a standing change and the rank-change chat notification. It + /// does **not** hide the row: a faction with this bit and [`VISIBLE`] is listed normally. + pub const HIDDEN: u8 = 0x04; + /// **This row is a header.** Not "force-invisible" — see the module doc; the client's own test + /// is `(flags >> 3) & 1` at `0x4d5acb`. The five factions that carry it (Alliance, Horde, + /// Steamwheedle Cartel, and the two battleground blocs) are the pane's header rows. + pub const HEADER: u8 = 0x08; + /// Overrides [`AT_WAR`]: war can never be declared, so the pane's box is disabled. Your own + /// side's city factions carry it. + pub const PEACE_FORCED: u8 = 0x10; + /// The player moved this faction to the pane's inactive bucket — which re-parents the row under + /// the synthetic "Inactive" header, rather than hiding it. + pub const INACTIVE: u8 = 0x20; + /// The two competing factions of a rival pair. **The 1.12 client never tests this bit** — it is + /// here to name the byte completely, not because anything reads it. + pub const RIVAL: u8 = 0x40; +} + impl Reaction { /// A reputation rank (`0..=7`, [`reputation_rank`]) collapsed to the three-way scale, the way /// the **nameplate** palette thresholds it (`0x7cbaa0`: `<= 1` hostile, `>= 4` friendly, else @@ -213,6 +307,9 @@ pub struct FactionCatalog { /// Faction id → the localized Name — what the item tooltip's "Requires - /// " line prints. names: HashMap, + /// Faction id → the localized Description — the paragraph the reputation pane's detail popup + /// prints under the faction name. Absent for most rows: only the pane's own factions carry one. + descriptions: HashMap, } impl FactionCatalog { @@ -234,6 +331,12 @@ impl FactionCatalog { self.names.get(&faction_id).map(String::as_str) } + /// The localized description of a `Faction.dbc` id — the reputation pane's detail paragraph. + /// `None` where the DBC leaves it empty, which is most non-pane factions. + pub fn faction_description(&self, faction_id: u32) -> Option<&str> { + self.descriptions.get(&faction_id).map(String::as_str) + } + /// Every faction that has a reputation slot, with its identity — the app builds the /// player's faction → rank map from this (base + wire standing, ranked). pub fn reputation_factions(&self) -> impl Iterator { @@ -363,6 +466,7 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result { let rs = parse(&bytes, faction_schema(), "Faction")?; let mut factions = HashMap::with_capacity(rs.records().len()); let mut names = HashMap::with_capacity(rs.records().len()); + let mut descriptions = HashMap::new(); for r in rs.records() { if let Some(id) = u32_at(r, 0) { let at = |i| u32_at(r, i).unwrap_or(0); @@ -371,6 +475,7 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result { id, FactionInfo { rep_index: iat(1), + team: at(18), race_masks: [at(2), at(3), at(4), at(5)], class_masks: [at(6), at(7), at(8), at(9)], base: [iat(10), iat(11), iat(12), iat(13)], @@ -381,6 +486,11 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result { if let Some(name) = crate::dbc::str_at(&rs, r, 19) { names.insert(id, name); } + // Desc0 (enUS) — column 28, past the 8 name columns and their flag word. Empty for + // most rows, so this map is far smaller than `names` and is left unsized. + if let Some(desc) = crate::dbc::str_at(&rs, r, 28) { + descriptions.insert(id, desc); + } } } @@ -401,6 +511,7 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result { factions, group_names, names, + descriptions, }) } @@ -647,4 +758,70 @@ mod tests { assert_eq!(cat.faction_group_name(4), Some("Horde")); assert_eq!(cat.faction_group_name(0), None); } + + /// **`team` is the reputation pane's tree, on the real table.** 1.12's `Faction.dbc` has no + /// parent column — field 18 (`team`) is the only edge it carries — and every group the pane + /// draws a header for is a set of rows sharing one `team`. Asserted by value on the real DBC + /// because a one-column slip here would silently produce a flat list that still *renders*. + /// + /// The counts are the whole table's, so a future patch chain that changes them fails loudly + /// rather than quietly regrouping the pane. Skips without client data. + #[test] + fn real_reputation_factions_group_under_their_team() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_faction_catalog(&mut chain).expect("load factions"); + + // 54 of the table's 190 rows carry a reputation slot; the rest are reaction-only. + assert_eq!(cat.reputation_factions().count(), 54); + + // The four Alliance cities sit under Alliance (469), the four Horde ones under Horde (67). + for id in [72 /* Stormwind */, 47, 69, 54] { + assert_eq!( + cat.reputation_faction(id).unwrap().team, + 469, + "faction {id}" + ); + } + for id in [76 /* Orgrimmar */, 81, 68, 530] { + assert_eq!(cat.reputation_faction(id).unwrap().team, 67, "faction {id}"); + } + // The Steamwheedle towns sit under the cartel itself (169) — a header that is also a + // reputation faction in its own right (slot 10), which is why "is a header" cannot be + // "has no reputation slot". + for id in [21 /* Booty Bay */, 369, 470, 577] { + assert_eq!( + cat.reputation_faction(id).unwrap().team, + 169, + "faction {id}" + ); + } + assert_eq!(cat.reputation_faction(169).unwrap().rep_index, 10); + // …and the parents themselves have no parent: they are the top of the tree. + for id in [469, 67, 169] { + assert_eq!(cat.reputation_faction(id).unwrap().team, 0, "faction {id}"); + } + // The pane's leftovers — Argent Dawn and the rest — carry no team at all. + assert_eq!(cat.reputation_faction(529).unwrap().team, 0); + + // Exactly five factions are named as a parent by somebody, and every one of them is a + // header the pane draws: Alliance, Horde, Steamwheedle Cartel, and the two BG blocs. + let mut parents: Vec = cat + .reputation_factions() + .map(|(_, f)| f.team) + .filter(|&t| t != 0) + .collect::>() + .into_iter() + .collect(); + parents.sort_unstable(); + assert_eq!(parents, [67, 169, 469, 891, 892]); + + // The detail popup's paragraph comes off the same row, past the eight name columns. + assert!( + cat.faction_description(529) + .is_some_and(|d| d.starts_with("An organization focused on protecting Azeroth")), + "Argent Dawn's description: {:?}", + cat.faction_description(529) + ); + } } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 01a981d4..e5616a61 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -43,7 +43,8 @@ mod itemsubclass; pub use itemsubclass::{load_item_sub_classes, ItemSubClassCatalog, ItemSubClassInfo}; mod factions; pub use factions::{ - load_faction_catalog, reputation_rank, FactionCatalog, FactionInfo, FactionTemplate, Reaction, + faction_flags, load_faction_catalog, reputation_rank, FactionCatalog, FactionInfo, + FactionTemplate, Reaction, }; mod creature_types; pub use creature_types::{load_creature_type_flags, CreatureTypeFlags}; diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index f80efffd..7c6c389b 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -325,6 +325,10 @@ pub enum SessionEvent { /// Mid-session reputation deltas (`SMSG_SET_FACTION_STANDING`): `(reputationListId, /// standing)` per changed slot — same standing convention as [`Self::Reputations`]. ReputationDelta { standings: Vec<(u32, i32)> }, + /// One reputation-list slot just became visible in the pane (`SMSG_SET_FACTION_VISIBLE`) — the + /// server's "you have now met these people". Carries no standing; it only lifts the slot's + /// visible flag, which is what decides whether the pane lists the row at all. + ReputationVisible { list_id: u32 }, /// A player character's identity (`SMSG_NAME_QUERY_RESPONSE`, answering our `CMSG_NAME_QUERY`). /// Unit names are **not** descriptor fields on the 1.12 wire — they arrive only through this /// query pair (players) and [`Self::CreatureName`] (creatures); the app caches them by guid/entry diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 420171eb..b06fbad6 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -594,6 +594,9 @@ pub fn decode(packet: ServerPacket) -> Vec { ServerPacket::SetFactionStanding { standings } => { vec![SessionEvent::ReputationDelta { standings }] } + ServerPacket::SetFactionVisible { list_id } => { + vec![SessionEvent::ReputationVisible { list_id }] + } ServerPacket::NameQueryResponse { guid, name, diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 4747fba6..8cb13baa 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -43,6 +43,7 @@ mod pet; mod pose; mod progression; mod quest; +mod reputation; mod roster; mod skills; mod social; @@ -150,6 +151,9 @@ pub use quest::{ QuestRequestItems, QuestRequiredItem, QuestRewardItem, QuestTemplate, QUEST_EMOTE_COUNT, QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, }; +pub use reputation::{ + set_faction_at_war, set_faction_inactive, set_watched_faction, WATCHED_FACTION_NONE, +}; pub use roster::{ CharCreateReq, CharEnumItem, Character, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, CHAR_CREATE_NAME_IN_USE, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 1dc84a44..62c3b194 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -56,7 +56,17 @@ pub const SMSG_TRIGGER_CINEMATIC: u16 = 0x00FA; pub const CMSG_COMPLETE_CINEMATIC: u16 = 0x00FC; pub const SMSG_MONSTER_MOVE: u16 = 0x00DD; pub const SMSG_INITIALIZE_FACTIONS: u16 = 0x0122; +/// A faction became visible in the reputation pane (VERIFIED vmangos `Opcodes_1_12_1.h`: 291, +/// sender `ReputationMgr::SendVisible`) — body one `u32` reputation-list slot. The server sets the +/// slot's `FACTION_FLAG_VISIBLE` on first contact and pushes *only* this, never a fresh standing: +/// a client that ignores it keeps a correct standing on a row the pane refuses to list. +pub const SMSG_SET_FACTION_VISIBLE: u16 = 0x0123; pub const SMSG_SET_FACTION_STANDING: u16 = 0x0124; +/// The reputation pane's three send verbs (VERIFIED vmangos `Opcodes.cpp`'s `DEFINE_HANDLER` +/// registrations: 293, 791, 792). Bodies in [`super::reputation`]; none is acked. +pub const CMSG_SET_FACTION_ATWAR: u16 = 0x0125; // 293 +pub const CMSG_SET_FACTION_INACTIVE: u16 = 0x0317; // 791 +pub const CMSG_SET_WATCHED_FACTION: u16 = 0x0318; // 792 pub const SMSG_AUTH_CHALLENGE: u16 = 0x01EC; pub const SMSG_AUTH_RESPONSE: u16 = 0x01EE; /// The Warden anticheat challenge. A server that sends this starts a response-timeout clock diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index f8e0a0b9..c1194d98 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -226,6 +226,13 @@ pub enum ServerPacket { SetFactionStanding { standings: Vec<(u32, i32)>, }, + /// `SMSG_SET_FACTION_VISIBLE` — one reputation-list slot just became visible in the pane + /// (vmangos `ReputationMgr::SendVisible`, pushed the first time the player meets the faction). + /// It carries **no standing**: the slot's existing standing is already correct, and this only + /// lifts `FACTION_FLAG_VISIBLE` on it. + SetFactionVisible { + list_id: u32, + }, /// `SMSG_NAME_QUERY_RESPONSE` — a player character's identity, answering `CMSG_NAME_QUERY`: /// guid, name, realm (1.12.1 carries it, empty on a single realm), race/gender/class as `u32`s /// (VERIFIED vmangos `NameQueryResponse::AppendBodyTo`). An unknown guid answers with an empty @@ -1125,6 +1132,7 @@ impl ServerPacket { ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(), ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(), ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(), + ServerPacket::SetFactionVisible { .. } => "SMSG_SET_FACTION_VISIBLE".into(), ServerPacket::NameQueryResponse { .. } => "SMSG_NAME_QUERY_RESPONSE".into(), ServerPacket::CreatureQueryResponse { .. } => "SMSG_CREATURE_QUERY_RESPONSE".into(), ServerPacket::PetNameQueryResponse { .. } => "SMSG_PET_NAME_QUERY_RESPONSE".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 25148fc9..230fc303 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -745,6 +745,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { } ServerPacket::SetFactionStanding { standings } } + opcode::SMSG_SET_FACTION_VISIBLE => ServerPacket::SetFactionVisible { + list_id: read_u32_le(&mut r)?, + }, opcode::SMSG_NAME_QUERY_RESPONSE => { let guid = read_u64_le(&mut r)?; let name = read_cstring(&mut r)?; diff --git a/crates/benilla-protocol/src/messages/reputation.rs b/crates/benilla-protocol/src/messages/reputation.rs new file mode 100644 index 00000000..62f0f852 --- /dev/null +++ b/crates/benilla-protocol/src/messages/reputation.rs @@ -0,0 +1,53 @@ +//! The reputation pane's three send verbs, and the one server push that is not a standing. +//! +//! Every faction the client can *change* is addressed by its **reputation-list id** — `Faction.dbc`'s +//! `reputationIndex`, the same slot the `SMSG_INITIALIZE_FACTIONS` array is positional in — never by +//! the `Faction.dbc` id and never by the panel's display row. The three bodies are byte-exact against +//! the vmangos reader side (`Server/Packets/Misc.cpp` + `Misc.h`, the `SetFactionAtWar` / +//! `SetFactionInactive` / `SetWatchedFaction` `ClientPacket`s), which is what a real server will +//! actually parse: +//! +//! | verb | opcode | body | +//! |---|---|---| +//! | at-war toggle | `CMSG_SET_FACTION_ATWAR` 0x125 | `u32 repListId`, `u8 flag` | +//! | inactive toggle | `CMSG_SET_FACTION_INACTIVE` 0x317 | `u32 repListId`, `u8 inactive` | +//! | watched faction | `CMSG_SET_WATCHED_FACTION` 0x318 | `i32 repListId` | +//! +//! **The watched verb is signed, and that is load-bearing.** Slot `0` is a real faction (the Bloodsail +//! Buccaneers hold `reputationIndex` 0 in 1.12's `Faction.dbc`), so "watch nothing" cannot be 0 on the +//! wire — vmangos writes the value straight into `PLAYER_FIELD_WATCHED_FACTION_INDEX` with +//! `SetInt32Value` (`Handlers/CharacterHandler.cpp` `HandleSetWatchedFactionOpcode`), and the descriptor +//! field's "none" is `-1`. FrameXML's own `SetWatchedFactionIndex(0)` passes a *display row* of 0 +//! meaning "no row", so the binding — not this layer — is where 0 becomes [`WATCHED_FACTION_NONE`]. +//! +//! None of the three is acked. The at-war and inactive flags come back as the flag byte of a fresh +//! `SMSG_INITIALIZE_FACTIONS`-shaped state only at the next login; within a session the client owns +//! its own optimistic flag copy, exactly as it owns the collapse state. The watched index comes back +//! as a `PLAYER_FIELD_WATCHED_FACTION_INDEX` descriptor update. + +/// The `PLAYER_FIELD_WATCHED_FACTION_INDEX` / `CMSG_SET_WATCHED_FACTION` sentinel for "watch no +/// faction" — see the module header on why it cannot be `0`. +pub const WATCHED_FACTION_NONE: i32 = -1; + +/// Body of `CMSG_SET_FACTION_ATWAR`: the reputation-list slot, then the desired at-war state. +/// +/// vmangos drops the request outright while the player is in combat +/// (`HandleSetFactionAtWarOpcode`), so a declare/withdraw mid-fight is silently nothing. +pub fn set_faction_at_war(rep_list_id: u32, at_war: bool) -> Vec { + let mut out = rep_list_id.to_le_bytes().to_vec(); + out.push(u8::from(at_war)); + out +} + +/// Body of `CMSG_SET_FACTION_INACTIVE`: the reputation-list slot, then the desired inactive state. +pub fn set_faction_inactive(rep_list_id: u32, inactive: bool) -> Vec { + let mut out = rep_list_id.to_le_bytes().to_vec(); + out.push(u8::from(inactive)); + out +} + +/// Body of `CMSG_SET_WATCHED_FACTION`: one **signed** reputation-list slot, or +/// [`WATCHED_FACTION_NONE`] to stop watching. +pub fn set_watched_faction(rep_list_id: i32) -> Vec { + rep_list_id.to_le_bytes().to_vec() +} 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 c2e34c05..fdeb1234 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -341,6 +341,14 @@ const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176; // UNIT_END + 0x210 / 0x211; its stale hex comments read 0x2C6/0x2C7, the same 6-low drift as COINAGE.) const FIELD_PLAYER_XP: u16 = 716; const FIELD_PLAYER_NEXT_LEVEL_XP: u16 = 717; +// PLAYER_FIELD_WATCHED_FACTION_INDEX (INT, PRIVATE — self only): the reputation-list slot whose bar +// rides the main menu bar, or -1 for none. SIGNED, and that matters — slot 0 is a real faction (the +// Bloodsail Buccaneers), so -1 is the only "watch nothing" (vmangos writes it with `SetInt32Value` +// in `HandleSetWatchedFactionOpcode`). Derived by the same server enum arithmetic as COINAGE above: +// UNIT_END(0xBC = 188) + 0x431(1073) = 1261. Cross-checked against the tested COINAGE anchor: +// 1176 + (0x431 − 0x3DC = 85) = 1261 ✓. (vmangos UpdateFields_1_12_1.h's stale hex comment reads +// 0x4E7 = 1255, the usual 6-low drift.) +const FIELD_PLAYER_WATCHED_FACTION_INDEX: u16 = 1261; // PLAYER_REST_STATE_EXPERIENCE (INT, PRIVATE — self only): the rested-XP pool, in BASE kill-XP // units — the server drains it 1:1 against a kill's base XP while granting +100% // (vmangos `Player::GetXPRestBonus`), and caps it at NEXT_LEVEL_XP × 1.5 / 2 (`SetRestBonus`), so 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 943fe8ff..f85e1020 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -193,6 +193,16 @@ impl ObjectFields { pub fn player_next_level_xp(&self) -> Option { self.get_u32(FIELD_PLAYER_NEXT_LEVEL_XP) } + /// `PLAYER_FIELD_WATCHED_FACTION_INDEX` — the reputation-list slot whose bar rides the main + /// menu bar (`GetWatchedFactionInfo`), or `-1` for none. Same PRIVATE/own-player caveat as + /// [`Self::player_xp`]. + /// + /// **Signed, and `0` is not "none".** Slot 0 is the Bloodsail Buccaneers, so the only + /// no-faction value is `-1` — reading this field as unsigned would silently watch them. + pub fn player_watched_faction(&self) -> Option { + self.get_u32(FIELD_PLAYER_WATCHED_FACTION_INDEX) + .map(|v| v as i32) + } /// `PLAYER_CHARACTER_POINTS1` — unspent talent points (`UnitCharacterPoints("player")`'s /// first return; the talent frame's "N talent points" line, decision 0304). Same /// PRIVATE/own-player caveat as [`Self::player_xp`]. 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 5dd394ee..b994519f 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -56,6 +56,13 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { assert_eq!(FIELD_PLAYER_AMMO_ID + 3, FIELD_PLAYER_BUYBACK_PRICE_1); assert_eq!(FIELD_PLAYER_FIELD_BYTES, UNIT_END + 0x40A); assert_eq!(FIELD_PLAYER_FIELD_BYTES + 1, FIELD_PLAYER_AMMO_ID); + // The watched reputation slot, anchored off the tested COINAGE the same way everything in the + // PLAYER block is: 0x431 − 0x3DC = 85 dwords above it. + assert_eq!(FIELD_PLAYER_WATCHED_FACTION_INDEX, UNIT_END + 0x431); + assert_eq!( + FIELD_PLAYER_WATCHED_FACTION_INDEX, + FIELD_PLAYER_FIELD_COINAGE + 85 + ); // The combo-target GUID is the two dwords immediately BEFORE the live-tested XP pair, so it // needs no anchor of its own. The binary agrees from the other side: `GetComboPoints 0x51a190` // reads it at `[player+0xe68]+0x838`, and 0x838/4 = 0x20E (decision 0875). diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index 05509373..ba17180b 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -71,6 +71,7 @@ mod pose; mod progression; mod pvp; mod quest; +mod reputation; mod selection; mod self_movement; mod skills; diff --git a/crates/benilla-protocol/src/world/writer/reputation.rs b/crates/benilla-protocol/src/world/writer/reputation.rs new file mode 100644 index 00000000..a4c51262 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/reputation.rs @@ -0,0 +1,42 @@ +//! The reputation pane's `WorldWriter` sends, mirroring [`crate::messages::reputation`]: the at-war +//! toggle, the inactive toggle, and the watched faction. Split out of `writer/mod.rs` (decision 0636). +//! +//! All three address a faction by its **reputation-list slot**, and none is acked — see the messages +//! module for the shapes and for why the watched slot is signed. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Declare or withdraw war on a faction (`CMSG_SET_FACTION_ATWAR`, layout in + /// [`messages::set_faction_at_war`]) — the pane's crossed-swords checkbox. vmangos DROPS this + /// while the player is in combat, so a flip mid-fight is silently nothing. + pub fn set_faction_at_war(&mut self, rep_list_id: u32, at_war: bool) -> Result<()> { + self.send( + opcode::CMSG_SET_FACTION_ATWAR, + &messages::set_faction_at_war(rep_list_id, at_war), + ) + } + + /// Move a faction to (or out of) the pane's inactive bucket (`CMSG_SET_FACTION_INACTIVE`, + /// layout in [`messages::set_faction_inactive`]). + pub fn set_faction_inactive(&mut self, rep_list_id: u32, inactive: bool) -> Result<()> { + self.send( + opcode::CMSG_SET_FACTION_INACTIVE, + &messages::set_faction_inactive(rep_list_id, inactive), + ) + } + + /// Watch a faction on the main bar, or [`messages::WATCHED_FACTION_NONE`] to stop + /// (`CMSG_SET_WATCHED_FACTION`, layout in [`messages::set_watched_faction`]). The answer comes + /// back as a `PLAYER_FIELD_WATCHED_FACTION_INDEX` descriptor update, not an ack. + pub fn set_watched_faction(&mut self, rep_list_id: i32) -> Result<()> { + self.send( + opcode::CMSG_SET_WATCHED_FACTION, + &messages::set_watched_faction(rep_list_id), + ) + } +} diff --git a/crates/benilla-protocol/tests/reputation.rs b/crates/benilla-protocol/tests/reputation.rs new file mode 100644 index 00000000..a283cfb8 --- /dev/null +++ b/crates/benilla-protocol/tests/reputation.rs @@ -0,0 +1,80 @@ +//! The reputation pane's wire: the pane's three client verbs, byte-exact against the vmangos +//! reader side, and the one server push (`SMSG_SET_FACTION_VISIBLE`) that carries no standing. +//! +//! The standing-bearing pair (`SMSG_INITIALIZE_FACTIONS` / `SMSG_SET_FACTION_STANDING`) already +//! has its goldens in `tests/simple_packets.rs`, where it landed with the unit-reaction decode. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, opcode}; +use benilla_protocol::ServerPacket; +use common::hx; + +/// `CMSG_SET_FACTION_ATWAR` (293): `u32 repListId`, `u8 flag` — the pane's crossed-swords box. +/// +/// Byte-exact against vmangos's reader (`WorldPackets::Misc::SetFactionAtWar::ReadFromWorldPacket`, +/// `Server/Packets/Misc.cpp`, over the `uint32 repListId` / `uint8 flag` fields in `Misc.h`), which +/// is the side that has to parse what we send. +#[test] +fn set_faction_at_war_body_is_slot_then_flag() { + assert_eq!(opcode::CMSG_SET_FACTION_ATWAR, 293); + // Slot 21 (Booty Bay is reputationIndex 1; 21 is Darnassus's), war ON. + assert_eq!(messages::set_faction_at_war(21, true), hx("1500000001")); + // …and OFF. The flag is a whole byte, not a bit — `0`, never a cleared bit in a mask. + assert_eq!(messages::set_faction_at_war(21, false), hx("1500000000")); + // Slot 0 is a REAL faction (the Bloodsail Buccaneers hold reputationIndex 0), so a zero slot + // is an ordinary request here — the "nothing" sentinel problem is the watched verb's alone. + assert_eq!(messages::set_faction_at_war(0, true), hx("0000000001")); +} + +/// `CMSG_SET_FACTION_INACTIVE` (791): `u32 repListId`, `u8 inactive` — the "move to inactive" box. +/// +/// Byte-exact against `WorldPackets::Misc::SetFactionInactive::ReadFromWorldPacket`. +#[test] +fn set_faction_inactive_body_is_slot_then_flag() { + assert_eq!(opcode::CMSG_SET_FACTION_INACTIVE, 791); + assert_eq!(messages::set_faction_inactive(54, true), hx("3600000001")); + assert_eq!(messages::set_faction_inactive(54, false), hx("3600000000")); +} + +/// `CMSG_SET_WATCHED_FACTION` (792): one **signed** `i32` slot, `-1` for "watch nothing". +/// +/// The signedness is the whole point and is why this has its own assert: vmangos writes the value +/// straight into `PLAYER_FIELD_WATCHED_FACTION_INDEX` with `SetInt32Value` +/// (`HandleSetWatchedFactionOpcode`), and slot `0` is a real faction — so a `0` here would watch the +/// Bloodsail Buccaneers rather than clear the bar. FrameXML's `SetWatchedFactionIndex(0)` means "no +/// display row"; translating that to [`messages::WATCHED_FACTION_NONE`] is the binding's job, above +/// this layer, and this test pins the two values apart so the translation cannot be skipped silently. +#[test] +fn set_watched_faction_body_is_a_signed_slot_and_none_is_minus_one() { + assert_eq!(opcode::CMSG_SET_WATCHED_FACTION, 792); + assert_eq!(messages::WATCHED_FACTION_NONE, -1); + assert_eq!(messages::set_watched_faction(11), hx("0b000000")); + assert_eq!( + messages::set_watched_faction(messages::WATCHED_FACTION_NONE), + hx("ffffffff") + ); + // The trap, asserted: watching slot 0 and watching nothing are different bytes. + assert_ne!( + messages::set_watched_faction(0), + messages::set_watched_faction(messages::WATCHED_FACTION_NONE) + ); +} + +/// `SMSG_SET_FACTION_VISIBLE` (291): one `u32` reputation-list slot, no standing +/// (vmangos `ReputationMgr::SendVisible` / `SetFactionVisible::AppendBodyTo`). +/// +/// The server pushes it the first time the player meets a faction. A client that drops it keeps a +/// correct standing for a row the pane will not list — which is exactly the failure this parse arm +/// exists to prevent, so the decode leg is asserted too. +#[test] +fn set_faction_visible_parses_and_decodes() { + assert_eq!(opcode::SMSG_SET_FACTION_VISIBLE, 291); + let p = messages::parse_server(opcode::SMSG_SET_FACTION_VISIBLE, &hx("0d000000")).unwrap(); + assert!(matches!(p, ServerPacket::SetFactionVisible { list_id: 13 })); + assert!(matches!( + decode(p)[..], + [SessionEvent::ReputationVisible { list_id: 13 }] + )); +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index e3bb4099..45839c74 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -90,6 +90,7 @@ mod pvp; mod quest; mod quest_log; mod region; +mod reputation; mod saved; mod scrollframe; mod session; @@ -154,6 +155,7 @@ pub use pet::{PetActionView, PetStats}; pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; pub(crate) use region::apply_font_parts; +pub use reputation::{FactionEntry, ReputationSend, ReputationState}; pub use session::SessionRequest; pub use shapeshift::ShapeshiftFormView; pub use skills::{SkillEntry, SkillsState}; @@ -498,6 +500,7 @@ impl UiScript { dressup::install(&lua)?; tradeskill::install(&lua)?; craft::install(&lua)?; + reputation::install(&lua)?; skills::install(&lua)?; item_stats::install(&lua)?; char_stats::install(&lua)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 17ac8b0a..8ddf14dd 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -5,10 +5,10 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena}; use super::{ backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect, - item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, session, skills, - slider, social, spellbook, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot, - AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, - UnitState, + item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, reputation, + session, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer, weapon_enchant, + ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, + SoundRequest, UnitState, }; // ───────────────────────────────────────────────────────────────────────────────────────────── @@ -905,6 +905,24 @@ pub(crate) struct Model { /// server's skill-field update, vmangos `SkillHandler.cpp`'s `SetSkill(id, 0, 0)` round trip). pub(crate) skill_abandons: Vec, + /// The reputation-pane snapshot the app pushes ([`reputation::ReputationState`]) and the + /// synthesized display tree built from it ([`reputation::UiScript::set_reputation`]) — the + /// reputation seam ([`reputation`]). + pub(crate) reputation: reputation::ReputationState, + pub(crate) reputation_groups: Vec, + /// The header groups the player has folded, by HEADER KEY (a `Faction.dbc` id, or the + /// synthetic `0` "Other" / `-1` "Inactive") — an identity a re-push cannot move, unlike a row + /// index. Reset to all-expanded-but-Inactive on every push, exactly as + /// [`Model::skills_collapsed`] is: the client's own rebuild does the same. + pub(crate) reputation_collapsed: HashSet, + /// The engine-held selection, by REPUTATION-LIST SLOT (not visible index — see + /// [`reputation`]'s module doc). + pub(crate) reputation_selected: Option, + /// Reputation verbs the pane's bindings queued since the app's last + /// [`UiScript::take_reputation_sends`] drain — the outbound seam. Unlike the skills abandon + /// above, the engine DOES mutate locally first: none of the three sends is acked. + pub(crate) reputation_sends: Vec, + /// Chat lines the input EditBox submitted (its `OnEnterPressed` → the `SubmitChatInput` Lua /// binding) since the app's last [`UiScript::take_chat_input`] drain — the outbound Lua→app seam /// for the chat input (the twin of `loot_picks`). The app routes each through its slash-command @@ -1246,6 +1264,11 @@ impl Model { skills_collapsed: HashSet::new(), skills_selected: None, skill_abandons: Vec::new(), + reputation: reputation::ReputationState::default(), + reputation_groups: Vec::new(), + reputation_collapsed: HashSet::new(), + reputation_selected: None, + reputation_sends: Vec::new(), } } diff --git a/crates/benilla-ui/src/script/reputation.rs b/crates/benilla-ui/src/script/reputation.rs new file mode 100644 index 00000000..5e9d9e56 --- /dev/null +++ b/crates/benilla-ui/src/script/reputation.rs @@ -0,0 +1,934 @@ +//! The reputation-pane bindings — the twelve `ReputationFrame` globals, driving a faithful port of +//! the real 1.12 Reputation tab (reference FrameXML `ReputationFrame.{xml,lua}`). +//! +//! **The display law here is byte-pinned**, not inferred: wow-5875-re +//! `system/ui/scratch/reputation-panel-law.md`, carved from the client's whole reputation TU +//! (`[0x4d5200, 0x4d6d80)`) by a §5 cross-check. Addresses below are that node's. +//! +//! Same split as [`super::skills`]: the app pushes a flat, unordered snapshot +//! ([`UiScript::set_reputation`], each row already resolved from `Faction.dbc` + the player's live +//! wire slot) and the ENGINE groups, sorts and folds. Every index the Lua API takes or returns is +//! 1-based into the resulting visible row list. +//! +//! ## The tree: a header is a FLAG, the parent pointer is only the key +//! +//! Two separate facts, and conflating them is what this module was written wrong for once: +//! +//! - **A row is a header because the server said so** — flag bit `0x08`, tested as +//! `entry.isHeader = (flags >> 3) & 1` at `0x4d5acb`. Every emulator names that bit +//! `INVISIBLE_FORCED`; the binary disagrees, and the binary wins. The two readings select the +//! *same five factions* on 1.12's data (Alliance, Horde, Steamwheedle Cartel and the two +//! battleground blocs all carry it), which is exactly why the wrong one survives a screenshot. +//! - **`Faction.dbc`'s `team` is the grouping key** — which header a row files under. An `INACTIVE` +//! row (flag `0x20`) is re-parented under the key `-1` instead of its own. +//! +//! A key whose header row is absent gets one **synthesized** (`0x4d5a70`) carrying that faction's +//! real id, so `GetFactionInfo` names it the ordinary way. Two keys name no faction at all and take +//! their text from global strings: `0` → `FACTION_OTHER`, `-1` → `FACTION_INACTIVE`. +//! +//! **A childless header is not drawn.** `GetNumFactions` (`ds:0xb73764`) is the total minus each +//! collapsed child *and each empty header*, over an array re-sorted so `[0, visible)` is exactly the +//! visible prefix — a flattened materialized view, not a skip. +//! +//! ## Ordering (the two real `qsort`s in `0x4d5c40`) +//! +//! Headers (`0x4d5dc0`): localized name ascending, with the two unresolved keys last and the +//! **larger raw key first**, so `0` "Other" precedes `-1` "Inactive". Rows (`0x4d5e70`): within a +//! header, the header row itself, then its children by localized name ascending. +//! +//! ## Collapse +//! +//! Client-side only — no opcode carries it, and it is persisted nowhere. The client's mask +//! (`ds:0x84a0a4`) stores **expanded** bits, so `isCollapsed` is its negation. **Every rebuild +//! resets it to all-expanded and then collapses the "Inactive" header**, which is why a fold here +//! does not survive a push (the skills pane's `expandedMask = 0xFFFFFFFF`, one door along). +//! `CollapseFactionHeader`/`ExpandFactionHeader` given `0` — or any non-header row — act on **every** +//! header. +//! +//! ## Where the numbers come from — all of them, the app +//! +//! This crate is engine-free by design (decision 0068: roxmltree + mlua, no DBC, no wire), so every +//! fidelity number arrives already computed, the same division [`super::skills`] draws. +//! `crates/benilla-app/src/ui_reputation.rs` owns: the race/class slot pick that gates membership, +//! adding `Faction.dbc`'s base to the wire standing (`wire + base` at `0x4d6370`), ranking the +//! total, and the rank's absolute window (`.rdata 0x80928c` — the same nine edges +//! `benilla_formats::reputation_rank` thresholds against). +//! +//! `standing_id` is `rank + 1`, 1..=8 (`FACTION_STANDING_LABEL`, `FACTION_BAR_COLORS[1..8]`). +//! `barMin`/`barMax`/`barValue` are **absolute**; the reference normalizes them itself (ref l.80-82). +//! `GetWatchedFactionInfo`'s `reaction` is that **same** 1..8 scale (`0x4d68a0` is `0x4d658a`'s +//! instruction), not the three-way unit-reaction one. +//! +//! ## The three sends, and which are optimistic +//! +//! `FactionToggleAtWar` (`0x4d6950` → `CMSG 0x125`) and `SetFactionInactive`/`SetFactionActive` +//! (`0x4d69b0`/`0x4d6a00` → `CMSG 0x317`) write the local flag **first** and then send; the inactive +//! pair also re-sorts locally, since the row changes header. `SetWatchedFactionIndex` (`0x4d6b60` → +//! `CMSG 0x318`) is **not** optimistic: the watched slot is `PLAYER_FIELD_WATCHED_FACTION_INDEX`, a +//! server field with no client mirror, so the bar moves only once the descriptor update lands. +//! +//! The at-war *toggle* enforces more than `canToggleAtWar` reports (`0x4d5fd0`): it also refuses +//! while the player is in combat, and applies the −3000 floor only when moving toward peace. Only +//! the reported predicate lives here; the combat half needs state this crate cannot see. + +use std::collections::HashMap; + +use mlua::{Lua, MultiValue, Value}; + +use super::Model; + +/// The synthetic header key for factions the player moved to the inactive bucket. +const KEY_INACTIVE: i64 = -1; +/// The synthetic header key for factions with no parent — the "Other" bucket. +const KEY_OTHER: i64 = 0; + +/// One reputation faction, app-resolved from `Faction.dbc` plus the player's live wire slot. +/// EXACT shape the app feed (`crates/benilla-app/src/ui_reputation.rs`) is written against — do not +/// rename. +#[derive(Clone, Debug, PartialEq)] +pub struct FactionEntry { + /// `Faction.dbc` id — the row's identity, and what a child's [`Self::parent_id`] names. + pub faction_id: u32, + /// `reputationIndex` — the slot every send addresses and the selection is held by. + pub rep_list_id: u32, + /// `Faction.dbc`'s `team`: the header key this row files under, `0` being the "Other" bucket. + /// Overridden to the "Inactive" bucket while [`Self::inactive`] is set. + pub parent_id: u32, + /// The localized `Faction.dbc` name — the bar's left-hand label, or a header's text. + pub name: String, + /// The localized `Faction.dbc` description — the detail popup's paragraph. Often empty. + pub description: String, + /// Total standing: the DBC race/class base plus the wire standing (the app adds them) — + /// `GetFactionInfo`'s `barValue`, absolute. + pub standing: i32, + /// The 1-based standing rank, `1`..=`8` (`FACTION_STANDING_LABEL`), app-ranked. + pub standing_id: u8, + /// The rank's absolute floor and ceiling — `GetFactionInfo`'s `barMin`/`barMax`. + pub bar_min: i32, + pub bar_max: i32, + /// Flag `0x01` — whether the pane lists this faction at all. The **only** flag gating list + /// membership; off until the player first meets them. + pub visible: bool, + /// Flag `0x08` — **this row is a header**, not "force-invisible" (see the module doc). + pub is_header: bool, + /// Flag `0x02`. **Engine-mutable**: the toggle flips it here, optimistically, unacked. + pub at_war: bool, + /// Flag `0x10` clear **and** `standing >= -3000` — what `GetFactionInfo` reports. The toggle + /// itself is stricter; see the module doc. + pub can_toggle_at_war: bool, + /// Flag `0x20`. **Engine-mutable**, same reason as [`Self::at_war`]; it re-parents the row under + /// the "Inactive" header rather than hiding it. + pub inactive: bool, +} + +/// A flat, unordered push of every reputation faction the player has a slot for — the ENGINE groups +/// and sorts (see the module doc). EXACT shape the app feed is written against. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct ReputationState { + pub entries: Vec, + /// `PLAYER_FIELD_WATCHED_FACTION_INDEX` as a reputation-list slot, or `None` for none. A server + /// field with no client mirror, so it rides the push and is never written here. + pub watched: Option, +} + +/// One outbound reputation verb the pane queued, drained by the app into its `WorldWriter`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReputationSend { + /// `CMSG_SET_FACTION_ATWAR`. + AtWar { rep_list_id: u32, at_war: bool }, + /// `CMSG_SET_FACTION_INACTIVE`. + Inactive { rep_list_id: u32, inactive: bool }, + /// `CMSG_SET_WATCHED_FACTION`; `None` is the wire's `-1`, NOT slot 0. + Watch(Option), +} + +/// One header group: its key, its header text, and the positions of its children. +/// Engine-internal; `pub(crate)` only because [`super::model::Model`] stores a `Vec` of them. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct FactionGroup { + /// The header key — a `Faction.dbc` id, or [`KEY_OTHER`] / [`KEY_INACTIVE`]. + key: i64, + /// The header row's text. Empty for the two synthetic keys, whose text is resolved against the + /// VM's own global strings when read. + name: String, + /// Positions into [`ReputationState::entries`], sorted by [`collate`]d name. + entries: Vec, +} + +/// One visible row: a **header** (carrying its group index) or a faction **bar** (carrying its +/// position into [`ReputationState::entries`]). +#[derive(Clone, Copy)] +enum Row { + Header(usize), + Entry(usize), +} + +impl super::UiScript { + /// Push the player's reputation snapshot, rebuilding the display tree. + /// + /// A rebuild resets the folds to **all expanded** and then collapses the "Inactive" header — the + /// client's own behaviour, and the reason a fold does not survive a standing tick. The selection + /// is re-anchored by reputation-list slot, dropped only if that faction left the push. + pub fn set_reputation(&mut self, state: ReputationState) { + let mut model = self.model_mut(); + let groups = build_groups(&state.entries); + if let Some(slot) = model.reputation_selected { + if !state.entries.iter().any(|e| e.rep_list_id == slot) { + model.reputation_selected = None; + } + } + model.reputation_collapsed.clear(); + model.reputation_collapsed.insert(KEY_INACTIVE); + model.reputation_groups = groups; + model.reputation = state; + } + + /// Drain the reputation verbs the pane queued since the last call — the app's outbound seam. + pub fn take_reputation_sends(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().reputation_sends) + } +} + +/// The header key a row files under: its parent, or the inactive bucket while that flag is set. +fn header_key(e: &FactionEntry) -> i64 { + if e.inactive { + KEY_INACTIVE + } else { + i64::from(e.parent_id) + } +} + +/// The VM's own text for a synthetic header key, so a localized client gets its own word. Falls back +/// to the enUS literals when the strings are absent (a bare test VM). +fn synthetic_header(lua: &Lua, key: i64) -> String { + let (global, fallback) = if key == KEY_INACTIVE { + ("FACTION_INACTIVE", "Inactive") + } else { + ("FACTION_OTHER", "Other") + }; + lua.globals() + .get::(global) + .unwrap_or_else(|_| fallback.into()) +} + +/// Case-insensitive name ordering, raw bytes breaking a tie — the client's own `stricmp`. +/// Per-module copy, as every seam module here keeps one. +fn collate(a: &str, b: &str) -> std::cmp::Ordering { + a.to_lowercase() + .cmp(&b.to_lowercase()) + .then_with(|| a.as_bytes().cmp(b.as_bytes())) +} + +/// Era booleans: `1` / `nil`, never `0` — `0` is truthy in Lua and every ref branch here is a bare +/// `if ( atWarWith )`. Per-module copy, as every seam module here keeps one. +fn era_bool(b: bool) -> Value { + if b { + Value::Integer(1) + } else { + Value::Nil + } +} + +/// Build the header groups (the module doc's law). +/// +/// Children are the **visible, non-header** rows, grouped by [`header_key`] and sorted by collated +/// name. A group's text comes from the header ROW carrying that key when there is one, and is left +/// empty for the two synthetic keys, resolved against the VM's global strings at read time. +/// **A childless header is not a group at all** — the client's own count drops it. +/// +/// Group order: named headers by collated name ascending, then the synthetic keys last with the +/// larger raw key first, so "Other" (`0`) precedes "Inactive" (`-1`). +fn build_groups(entries: &[FactionEntry]) -> Vec { + let name_of: HashMap = entries + .iter() + .filter(|e| e.is_header) + .map(|e| (i64::from(e.faction_id), e.name.as_str())) + .collect(); + + let mut by_key: HashMap> = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + if e.visible && !e.is_header { + by_key.entry(header_key(e)).or_default().push(i); + } + } + + let mut groups: Vec = by_key + .into_iter() + .map(|(key, mut children)| { + children.sort_by(|&a, &b| collate(&entries[a].name, &entries[b].name)); + FactionGroup { + key, + name: name_of.get(&key).map_or(String::new(), |n| (*n).into()), + entries: children, + } + }) + .collect(); + groups.sort_by(|a, b| { + let synthetic = |k: i64| k <= KEY_OTHER; + match (synthetic(a.key), synthetic(b.key)) { + // Larger raw key first among the synthetics: 0 "Other" before -1 "Inactive". + (true, true) => b.key.cmp(&a.key), + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => collate(&a.name, &b.name), + } + }); + groups +} + +/// The visible row list: every group's header, plus the children of the expanded ones. +fn rows(model: &Model) -> Vec { + let mut out = Vec::new(); + for (gi, g) in model.reputation_groups.iter().enumerate() { + out.push(Row::Header(gi)); + if !model.reputation_collapsed.contains(&g.key) { + for &ei in &g.entries { + out.push(Row::Entry(ei)); + } + } + } + out +} + +fn num_rows(model: &Model) -> usize { + rows(model).len() +} + +/// The entry at a 1-based visible index, or `None` on a header or out of range. +fn entry_at(model: &Model, index: usize) -> Option<&FactionEntry> { + match rows(model).get(index.checked_sub(1)?)? { + Row::Entry(ei) => model.reputation.entries.get(*ei), + Row::Header(_) => None, + } +} + +/// The mutable twin of [`entry_at`] — the at-war/inactive flips write through it. +fn entry_at_mut(model: &mut Model, index: usize) -> Option<&mut FactionEntry> { + let row = *rows(model).get(index.checked_sub(1)?)?; + match row { + Row::Entry(ei) => model.reputation.entries.get_mut(ei), + Row::Header(_) => None, + } +} + +/// Fold or unfold by 1-based visible index. Index `0` — **or any index that is not a header** — acts +/// on every header, which is the client's own fall-through (`0x4d6a50`/`0x4d6aa0`), not a tolerance +/// invented here. +fn set_collapsed(model: &mut Model, index: usize, collapse: bool) { + let one = index + .checked_sub(1) + .and_then(|n| rows(model).get(n).copied()) + .and_then(|row| match row { + Row::Header(gi) => model.reputation_groups.get(gi).map(|g| g.key), + Row::Entry(_) => None, + }); + let keys: Vec = match one { + Some(key) => vec![key], + None => model.reputation_groups.iter().map(|g| g.key).collect(), + }; + for key in keys { + if collapse { + model.reputation_collapsed.insert(key); + } else { + model.reputation_collapsed.remove(&key); + } + } +} + +/// `GetFactionInfo`'s out-of-range answer — **eleven values, not one nil** (`0x4d5fa0`'s miss path): +/// `nil, nil, 1, 0, 0, 0, nil, nil, nil, nil, nil`. The `1` in the `standingID` slot is the +/// client's, and it is load-bearing: the reference indexes `FACTION_BAR_COLORS[standingID]` +/// unguarded, so a `nil` there would be an error rather than a blank row. +fn unknown_faction() -> MultiValue { + let mut out = vec![Value::Nil, Value::Nil, Value::Integer(1)]; + out.extend(std::iter::repeat_n(Value::Integer(0), 3)); + out.extend(std::iter::repeat_n(Value::Nil, 5)); + debug_assert_eq!(out.len(), 11, "the tuple is eleven wide on every path"); + MultiValue::from_vec(out) +} + +/// Register the reputation-pane globals. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + + // GetNumFactions() → the visible row count (0 before any push). + g.set( + "GetNumFactions", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(num_rows(&model) as i64) + })?, + )?; + + // GetFactionInfo(index) → the ref's own eleven (ref-ReputationFrame.lua l.51): + // name, description, standingID, barMin, barMax, barValue, atWarWith, canToggleAtWar, + // isHeader, isCollapsed, isWatched. 1-based; ELEVEN wide on every path, a miss included. + g.set( + "GetFactionInfo", + lua.create_function(|lua, index: usize| { + let model = lua.app_data_ref::().expect("model app_data"); + let Some(row) = index + .checked_sub(1) + .and_then(|n| rows(&model).get(n).copied()) + else { + return Ok(unknown_faction()); + }; + match row { + Row::Header(gi) => { + let grp = &model.reputation_groups[gi]; + let collapsed = model.reputation_collapsed.contains(&grp.key); + let name = if grp.name.is_empty() { + synthetic_header(lua, grp.key) + } else { + grp.name.clone() + }; + Ok(MultiValue::from_vec(vec![ + Value::String(lua.create_string(&name)?), + Value::String(lua.create_string("")?), // description + Value::Integer(0), // standingID + Value::Integer(0), // barMin + Value::Integer(0), // barMax + Value::Integer(0), // barValue + Value::Nil, // atWarWith + Value::Nil, // canToggleAtWar + Value::Integer(1), // isHeader + era_bool(collapsed), // isCollapsed + Value::Nil, // isWatched + ])) + } + Row::Entry(ei) => { + let e = &model.reputation.entries[ei]; + Ok(MultiValue::from_vec(vec![ + Value::String(lua.create_string(&e.name)?), + Value::String(lua.create_string(&e.description)?), + Value::Integer(i64::from(e.standing_id)), + Value::Integer(i64::from(e.bar_min)), + Value::Integer(i64::from(e.bar_max)), + Value::Integer(i64::from(e.standing)), + era_bool(e.at_war), + era_bool(e.can_toggle_at_war), + Value::Nil, // isHeader + Value::Nil, // isCollapsed + era_bool(model.reputation.watched == Some(e.rep_list_id)), + ])) + } + } + })?, + )?; + + // GetSelectedFaction() → the selected row's VISIBLE index, or 0 for none (the ref compares + // against 0 outright). The selection is HELD by slot, so this re-finds the row each call. + g.set( + "GetSelectedFaction", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + let Some(slot) = model.reputation_selected else { + return Ok(0i64); + }; + let found = rows(&model).iter().position(|r| match r { + Row::Entry(ei) => model.reputation.entries[*ei].rep_list_id == slot, + Row::Header(_) => false, + }); + Ok(found.map_or(0, |i| i as i64 + 1)) + })?, + )?; + + // SetSelectedFaction(index) — a VISIBLE index; a header or an out-of-range index clears it. + g.set( + "SetSelectedFaction", + lua.create_function(|lua, index: usize| { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.reputation_selected = entry_at(&model, index).map(|e| e.rep_list_id); + Ok(()) + })?, + )?; + + // CollapseFactionHeader(index) / ExpandFactionHeader(index) — a VISIBLE header index; `0` or a + // non-header row acts on EVERY header (the client's own fall-through). + g.set( + "CollapseFactionHeader", + lua.create_function(|lua, index: usize| { + let mut model = lua.app_data_mut::().expect("model app_data"); + set_collapsed(&mut model, index, true); + Ok(()) + })?, + )?; + g.set( + "ExpandFactionHeader", + lua.create_function(|lua, index: usize| { + let mut model = lua.app_data_mut::().expect("model app_data"); + set_collapsed(&mut model, index, false); + Ok(()) + })?, + )?; + + // FactionToggleAtWar(index) — flip our own AT_WAR bit and queue the send, optimistically + // (`0x4d6950` writes locally first, and nothing acks it). + g.set( + "FactionToggleAtWar", + lua.create_function(|lua, index: usize| { + let mut model = lua.app_data_mut::().expect("model app_data"); + let Some(e) = entry_at_mut(&mut model, index) else { + return Ok(()); + }; + if !e.can_toggle_at_war { + return Ok(()); + } + e.at_war = !e.at_war; + let send = ReputationSend::AtWar { + rep_list_id: e.rep_list_id, + at_war: e.at_war, + }; + model.reputation_sends.push(send); + Ok(()) + })?, + )?; + + // IsFactionInactive(index) / SetFactionInactive(index) / SetFactionActive(index) — optimistic + // too, and the flip RE-PARENTS the row under the "Inactive" header, so the tree is rebuilt + // locally (`0x4d69b0`'s own `0x4d5c40` call). The folds are kept across that regroup: it is not + // a server rebuild, which is the thing that resets them. + g.set( + "IsFactionInactive", + lua.create_function(|lua, index: usize| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(era_bool( + entry_at(&model, index).is_some_and(|e| e.inactive), + )) + })?, + )?; + for (name, inactive) in [("SetFactionInactive", true), ("SetFactionActive", false)] { + g.set( + name, + lua.create_function(move |lua, index: usize| { + let mut model = lua.app_data_mut::().expect("model app_data"); + let Some(e) = entry_at_mut(&mut model, index) else { + return Ok(()); + }; + e.inactive = inactive; + let send = ReputationSend::Inactive { + rep_list_id: e.rep_list_id, + inactive, + }; + model.reputation_sends.push(send); + let groups = build_groups(&model.reputation.entries); + model.reputation_groups = groups; + Ok(()) + })?, + )?; + } + + // GetWatchedFactionInfo() → name, reaction, min, max, value — or a single nil when nothing is + // watched (the ref's `if ( name )` gate). `reaction` is the SAME 1-based standingID scale. + g.set( + "GetWatchedFactionInfo", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + let watched = model.reputation.watched.and_then(|slot| { + model + .reputation + .entries + .iter() + .find(|e| e.rep_list_id == slot) + }); + let Some(e) = watched else { + return Ok(MultiValue::from_vec(vec![Value::Nil])); + }; + Ok(MultiValue::from_vec(vec![ + Value::String(lua.create_string(&e.name)?), + Value::Integer(i64::from(e.standing_id)), + Value::Integer(i64::from(e.bar_min)), + Value::Integer(i64::from(e.bar_max)), + Value::Integer(i64::from(e.standing)), + ])) + })?, + )?; + + // SetWatchedFactionIndex(index) — a VISIBLE index, `0` meaning "stop watching". **Not + // optimistic** (`0x4d6b60`): the watched slot lives in PLAYER_FIELD_WATCHED_FACTION_INDEX with + // no client mirror, so the bar only moves once the descriptor update comes back. The `0` → `-1` + // translation happens at the app's wire edge, since slot 0 is a real faction. + g.set( + "SetWatchedFactionIndex", + lua.create_function(|lua, index: usize| { + let mut model = lua.app_data_mut::().expect("model app_data"); + let slot = entry_at(&model, index).map(|e| e.rep_list_id); + model.reputation_sends.push(ReputationSend::Watch(slot)); + Ok(()) + })?, + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::script::UiScript; + + /// An ordinary bar row: visible, not a header. `parent` 0 puts it in the "Other" bucket. + fn entry(faction_id: u32, rep_list_id: u32, parent_id: u32, name: &str) -> FactionEntry { + FactionEntry { + faction_id, + rep_list_id, + parent_id, + name: name.into(), + description: format!("About the {name}."), + standing: 4000, + standing_id: 5, + bar_min: 3000, + bar_max: 9000, + visible: true, + is_header: false, + at_war: false, + can_toggle_at_war: true, + inactive: false, + } + } + + /// A header row as the real wire delivers one: flag `0x08` set, and **not** `VISIBLE` — the + /// Steamwheedle Cartel's actual byte is `0x08` alone. A header is used for its name, never + /// drawn as a bar, so its own visibility is irrelevant and this fixture proves it. + fn header(faction_id: u32, rep_list_id: u32, name: &str) -> FactionEntry { + FactionEntry { + visible: false, + is_header: true, + ..entry(faction_id, rep_list_id, 0, name) + } + } + + /// Two named groups and the bucket, pushed OUT of order so the engine's own sort is what shows: + /// Alliance (469) over Stormwind + Ironforge, Steamwheedle (169) over Booty Bay, and two + /// parentless factions. Bloodsail Buccaneers is here because it really does hold slot **0** — + /// the value that must never be confused with "watch nothing". + fn state() -> ReputationState { + ReputationState { + entries: vec![ + entry(72, 19, 469, "Stormwind"), + entry(529, 13, 0, "Argent Dawn"), + header(469, 11, "Alliance"), + entry(21, 1, 169, "Booty Bay"), + entry(47, 20, 469, "Ironforge"), + header(169, 10, "Steamwheedle Cartel"), + entry(87, 0, 0, "Bloodsail Buccaneers"), + ], + watched: None, + } + } + + fn seated() -> UiScript { + let mut s = UiScript::new().expect("VM"); + s.set_reputation(state()); + s + } + + /// Every visible row's name, in order — the shape assertion the rest lean on. + fn names(s: &UiScript) -> Vec { + s.eval::>( + "local t = {} for i = 1, GetNumFactions() do t[i] = (GetFactionInfo(i)) end return t", + ) + .expect("names") + } + + /// **The tree.** Headers are the rows the server FLAGGED as headers (`0x08`), grouped by the + /// children's `team`; a header is never also a bar; groups and children sort by name; and the + /// parentless bucket comes last under `FACTION_OTHER`. + /// + /// The fixture's headers are pushed WITHOUT the visible flag, which is what the real wire + /// sends — so this also pins that a header's own visibility does not gate its group. + #[test] + fn the_header_flag_builds_the_tree_and_other_comes_last() { + let s = seated(); + assert_eq!( + names(&s), + [ + "Alliance", + "Ironforge", + "Stormwind", + "Steamwheedle Cartel", + "Booty Bay", + "Other", + "Argent Dawn", + "Bloodsail Buccaneers", + ], + "two named groups sorted by header, then the bucket; children sorted within" + ); + let (name, _desc, standing_id, _min, _max, _val, at_war, toggle, is_header) = s + .eval::<(String, String, i64, i64, i64, i64, Value, Value, i64)>( + "return GetFactionInfo(1)", + ) + .unwrap(); + assert_eq!(name, "Alliance", "named from the flagged header row"); + assert_eq!(is_header, 1); + assert_eq!(standing_id, 0, "a header carries no standing"); + assert!(at_war.is_nil() && toggle.is_nil(), "nor any war state"); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **A childless header is not a row at all** — the client's own count drops it, so a header + /// whose children are all unmet must not leave an empty group behind. + #[test] + fn a_header_with_no_visible_children_is_dropped() { + let mut s = UiScript::new().expect("VM"); + let mut st = state(); + // Unmeet both of Alliance's cities; Alliance itself stays in the push. + for e in &mut st.entries { + if e.parent_id == 469 { + e.visible = false; + } + } + s.set_reputation(st); + assert!( + !names(&s).iter().any(|n| n == "Alliance"), + "an empty header is not drawn; got {:?}", + names(&s) + ); + assert_eq!(names(&s)[0], "Steamwheedle Cartel"); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **`GetFactionInfo`'s eleven, on a bar** — and eleven on a MISS too, which is the client's own + /// shape (`nil, nil, 1, 0, 0, 0, nil×5`). The `1` matters: the reference indexes + /// `FACTION_BAR_COLORS[standingID]` unguarded, so a nil there would raise rather than blank. + #[test] + fn a_bar_row_returns_the_references_eleven_and_so_does_a_miss() { + let s = seated(); + let got = s + .eval::<( + String, + String, + i64, + i64, + i64, + i64, + Value, + Value, + Value, + Value, + Value, + )>("return GetFactionInfo(3)") + .unwrap(); + assert_eq!(got.0, "Stormwind"); + assert_eq!(got.1, "About the Stormwind."); + assert_eq!(got.2, 5, "standingID is 1-based: FACTION_STANDING_LABEL5"); + assert_eq!((got.3, got.4, got.5), (3000, 9000, 4000), "absolute"); + assert!(got.6.is_nil(), "atWarWith"); + assert_eq!(got.7, Value::Integer(1), "canToggleAtWar"); + assert!(got.8.is_nil() && got.9.is_nil(), "isHeader/isCollapsed"); + assert!(got.10.is_nil(), "isWatched"); + + for miss in ["GetFactionInfo(99)", "GetFactionInfo(0)"] { + let n = s + .eval::(&format!("return select(\"#\", {miss})")) + .unwrap(); + assert_eq!(n, 11, "{miss} must still be eleven wide"); + let sid = s + .eval::(&format!("local _,_,s = {miss} return s")) + .unwrap(); + assert_eq!(sid, 1, "{miss}'s standingID slot is the client's 1"); + } + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **Folding hides a group's children and shrinks the count**, and index `0` (or any non-header + /// row) acts on EVERY header — the client's own fall-through, not a tolerance invented here. + #[test] + fn folding_shrinks_the_list_and_index_zero_folds_everything() { + let s = seated(); + s.run("CollapseFactionHeader(1)").unwrap(); + assert_eq!( + names(&s), + [ + "Alliance", + "Steamwheedle Cartel", + "Booty Bay", + "Other", + "Argent Dawn", + "Bloodsail Buccaneers" + ] + ); + assert_eq!( + s.eval::("local _,_,_,_,_,_,_,_,_,c = GetFactionInfo(1) return c") + .unwrap(), + Value::Integer(1), + "and reports itself collapsed" + ); + s.run("ExpandFactionHeader(1)").unwrap(); + assert_eq!(names(&s).len(), 8, "expanded again"); + + // A BAR row is not a header, so it takes the same fall-through: index 2 is Ironforge. + s.run("CollapseFactionHeader(2)").unwrap(); + assert_eq!( + names(&s), + ["Alliance", "Steamwheedle Cartel", "Other"], + "a non-header index folds every header" + ); + // …and so does index 0, and so does an index off the end of the list. + s.run("ExpandFactionHeader(0)").unwrap(); + assert_eq!(names(&s).len(), 8, "index 0 unfolds every header"); + s.run("CollapseFactionHeader(99)").unwrap(); + assert_eq!(names(&s).len(), 3, "so does an out-of-range index"); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **A push RESETS the folds** — all expanded, then "Inactive" collapsed. The client rebuilds + /// its expand mask on every server refresh, so a fold cannot survive a standing tick. + #[test] + fn a_push_resets_the_folds() { + let mut s = seated(); + s.run("CollapseFactionHeader(1)").unwrap(); + assert_eq!(names(&s).len(), 6, "folded"); + let mut ticked = state(); + ticked.entries[0].standing = 12345; + s.set_reputation(ticked); + assert_eq!(names(&s).len(), 8, "the rebuild unfolded it again"); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **Inactive re-parents a row under the synthetic "Inactive" header, which starts collapsed.** + /// It does not hide the faction, and it does not touch the bucket the row came from. + #[test] + fn an_inactive_faction_moves_under_the_inactive_header() { + let mut s = UiScript::new().expect("VM"); + let mut st = state(); + st.entries[1].inactive = true; // Argent Dawn, an "Other" row + s.set_reputation(st); + assert_eq!( + names(&s), + [ + "Alliance", + "Ironforge", + "Stormwind", + "Steamwheedle Cartel", + "Booty Bay", + "Other", + "Bloodsail Buccaneers", + "Inactive", + ], + "Inactive sorts after Other, and arrives COLLAPSED so its child is not listed" + ); + // Unfolding it reveals exactly the re-parented row. + s.run("ExpandFactionHeader(8)").unwrap(); + assert_eq!(names(&s).last().unwrap(), "Argent Dawn"); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **Selection is held by reputation slot, not by row.** `GetSelectedFaction` answers `0` for + /// none — what the ref's own teardown compares against — and re-finds the row after a reorder. + #[test] + fn selection_follows_the_faction_not_the_row_index() { + let mut s = seated(); + assert_eq!(s.eval::("return GetSelectedFaction()").unwrap(), 0); + s.run("SetSelectedFaction(3)").unwrap(); // Stormwind + assert_eq!(s.eval::("return GetSelectedFaction()").unwrap(), 3); + // Fold Alliance: Stormwind is no longer a visible row, so there is no index to answer. + s.run("CollapseFactionHeader(1)").unwrap(); + assert_eq!(s.eval::("return GetSelectedFaction()").unwrap(), 0); + s.run("ExpandFactionHeader(1)").unwrap(); + assert_eq!(s.eval::("return GetSelectedFaction()").unwrap(), 3); + // Selecting a HEADER clears the selection rather than selecting the group. + s.run("SetSelectedFaction(1)").unwrap(); + assert_eq!(s.eval::("return GetSelectedFaction()").unwrap(), 0); + // A push that drops the faction entirely drops the selection with it. + s.run("SetSelectedFaction(3)").unwrap(); + let mut without = state(); + without.entries.retain(|e| e.faction_id != 72); + s.set_reputation(without); + assert_eq!(s.eval::("return GetSelectedFaction()").unwrap(), 0); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **The two unacked toggles flip locally and queue their sends**; a peace-forced faction + /// refuses (the ref disables the box, but an addon can still call the global), and going + /// inactive re-groups on the spot without waiting for a server rebuild. + #[test] + fn the_war_and_inactive_toggles_flip_locally_and_queue_their_sends() { + let mut s = seated(); + s.run("FactionToggleAtWar(3)").unwrap(); // Stormwind + assert_eq!( + s.eval::("local _,_,_,_,_,_,w = GetFactionInfo(3) return w") + .unwrap(), + Value::Integer(1), + "the pane shows war immediately — nothing acks this" + ); + s.run("SetFactionInactive(3)").unwrap(); + assert_eq!( + names(&s).last().unwrap(), + "Inactive", + "and the row re-parents on the spot, without a server rebuild" + ); + assert_eq!( + s.take_reputation_sends(), + [ + ReputationSend::AtWar { + rep_list_id: 19, + at_war: true + }, + ReputationSend::Inactive { + rep_list_id: 19, + inactive: true + }, + ] + ); + assert!(s.take_reputation_sends().is_empty(), "the drain empties"); + + // A peace-forced row: no flip, no send. + let mut forced = state(); + forced.entries[0].can_toggle_at_war = false; + s.set_reputation(forced); + s.run("FactionToggleAtWar(3)").unwrap(); + assert!(s + .eval::("local _,_,_,_,_,_,w = GetFactionInfo(3) return w") + .unwrap() + .is_nil()); + assert!(s.take_reputation_sends().is_empty()); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } + + /// **The watch bar's five, the `0` that means "nothing", and the fact that watching is NOT + /// optimistic.** The watched slot is a server field with no client mirror, so the pane shows a + /// new bar only after the descriptor update comes back as a fresh push — clicking alone must + /// change nothing on screen. + #[test] + fn watching_queues_a_send_but_does_not_move_the_bar_itself() { + let mut s = seated(); + assert!( + s.eval::("return GetWatchedFactionInfo()") + .unwrap() + .is_nil(), + "nothing watched → one nil, which is the ref's own `if ( name )` gate" + ); + + s.run("SetWatchedFactionIndex(3)").unwrap(); // Stormwind + assert!( + s.eval::("return GetWatchedFactionInfo()") + .unwrap() + .is_nil(), + "still nothing: the server owns this field, so the click alone cannot move the bar" + ); + assert_eq!(s.take_reputation_sends(), [ReputationSend::Watch(Some(19))]); + + // The descriptor update arrives as the next push, and NOW the bar reads. + let mut watched = state(); + watched.watched = Some(19); + s.set_reputation(watched); + assert_eq!( + s.eval::<(String, i64, i64, i64, i64)>("return GetWatchedFactionInfo()") + .unwrap(), + ("Stormwind".into(), 5, 3000, 9000, 4000) + ); + assert_eq!( + s.eval::("local _,_,_,_,_,_,_,_,_,_,w = GetFactionInfo(3) return w") + .unwrap(), + Value::Integer(1), + "and the row reports itself watched" + ); + + // Watching the row that IS slot 0, then clearing — the two must not collapse together. + s.run("SetWatchedFactionIndex(8)").unwrap(); // Bloodsail Buccaneers, rep slot 0 + assert_eq!(s.take_reputation_sends(), [ReputationSend::Watch(Some(0))]); + s.run("SetWatchedFactionIndex(0)").unwrap(); + assert_eq!(s.take_reputation_sends(), [ReputationSend::Watch(None)]); + assert!(s.errors().is_empty(), "{:#?}", s.errors()); + } +}