settings boot at 1.12's defaults, and a cold loot no longer errors
- a setting's default is the reference's, enforced: eight player-facing options booted at something a stock client does not — `UnitNameNPC`, `UnitNameOwn`, `nameplateShowEnemies`, `ChatBubblesParty`, `cameraDistanceMaxFactor`, `QUEST_FADING_DISABLE`, `SHOW_BUFF_DURATIONS`, `SHOW_COMBAT_TEXT` — each a defensible "turn this on so I can see it while you build it" on its day and invisible as a set; the CVar table grows a mandatory `Reference` column (Same / Overridden / Deviates-with-reason) that a test checks in both directions, the FrameXML saved-variable store is compared against the reference's own `UIOptionsFrame.lua` off the chain (which found `SHOW_COMBAT_TEXT`, declared inside a table walked by a loop where a grep could not see it), and the wow-re census of all 214 `CVar::Register` sites folded in — `WorldDetail` back to `same`, since `GetWorldDetail` reads only `SmallCull` and the reference's own slider reads Medium at boot - looting on a cold item cache raised `LootFrame.lua:85: attempt to index local 'color'` out of the stock window: `GetLootSlotInfo` answered nil where the reference answers cache-miss SENTINELS — quality −1 (which is exactly why stock `UIParent.lua` builds `ITEM_QUALITY_COLORS` over `for i = -1, 6`), "" for a name in flight, `INV_Misc_QuestionMark` for a display-info miss — and four values on every leg (`mov eax,4` at `0x4c2d07` is unconditional); `GetLootRollItemInfo` takes its own miss tail (`nil, nil, 1.0, 1.0, nil`), `GetItemQualityColor(-1)` reads Common because the compare is unsigned, and the window now WAITS for every item template before opening as `0x4c1cb0` does — there is no repaint path in 1.12 to fall back on, so deferring IS the mechanism - the coin row is quality 0 (grey), not white — `0x4c23a0`'s coin leg falls to `xor eax,eax` before the item-cache read — and its icon walks the reference's six-step ladder (`0x6c6307`: one icon per decade of copper; two of the six were not in our set at all, and the pairs do not fall where "highest nonzero denomination" put them) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,10 @@
|
||||
frame registers. Where our behaviour departs from the spec, it is named:
|
||||
- The ref is load-on-demand (`UIParentLoadAddOn` when SHOW_COMBAT_TEXT == "1"); we load
|
||||
always and let CombatText_UpdateDisplayedMessages gate registration, ref-identically.
|
||||
- SHOW_COMBAT_TEXT defaults "1" here (ref "0") — the director's ask IS the enabled
|
||||
experience; every sub-toggle keeps its ref default (UIOptionsFrame.lua l.69-84).
|
||||
- SHOW_COMBAT_TEXT defaulted "1" here against the ref's "0" from 0578 (the director's ask
|
||||
was the enabled experience) until 1804 put the whole option family back on the
|
||||
reference's own values. All fourteen now sit at the spec's defaults
|
||||
(UIOptionsFrame.lua l.69-84), and the Combat page's checkbox is where it turns on.
|
||||
- The ref's virtual FontString template (CombatTextTemplate) expands to 20 identical
|
||||
strings; region templates are outside the v1 loader's object model (BuffFrame.xml has
|
||||
the precedent note), so the 20 instances inherit the CombatTextFont Font object and
|
||||
@@ -76,9 +78,11 @@
|
||||
COMBAT_TEXT_LOW_MANA_THRESHOLD = 0.2
|
||||
|
||||
-- The option family: every saved global this feature owns, next to the value it ships
|
||||
-- with. All fourteen sit at the spec's defaults (UIOptionsFrame.lua l.69-84) except the
|
||||
-- master SHOW_COMBAT_TEXT, which ships "1" against the spec's "0" — the director's ask IS
|
||||
-- the enabled experience (see the header).
|
||||
-- with. **All fourteen are the spec's own defaults** (UIOptionsFrame.lua l.69-84), the
|
||||
-- master SHOW_COMBAT_TEXT included — it shipped "1" against the spec's "0" from 0578 to
|
||||
-- 1804 (see the header). Held to that by
|
||||
-- `reference_ui::tests::our_saved_ui_globals_default_to_the_references_own_values`, which
|
||||
-- reads the reference's file off the player's chain rather than trusting this comment.
|
||||
--
|
||||
-- Each name is assigned AT FILE SCOPE and registered for saving in the same pass, because
|
||||
-- the load order is what makes a saved value stick (decision 1128): defaults first, the
|
||||
@@ -88,7 +92,7 @@
|
||||
-- saved chunk — so a default lives in exactly one place, this table, and the panel cannot
|
||||
-- drift from it.
|
||||
local combatTextOptions = {
|
||||
{"SHOW_COMBAT_TEXT", "1"},
|
||||
{"SHOW_COMBAT_TEXT", "0"},
|
||||
{"COMBAT_TEXT_SHOW_LOW_HEALTH_MANA", "1"},
|
||||
{"COMBAT_TEXT_SHOW_AURAS", "1"},
|
||||
{"COMBAT_TEXT_SHOW_AURA_FADE", "0"},
|
||||
|
||||
@@ -78,8 +78,11 @@
|
||||
-- engine binding over wow-5875-re RF-0055's byte-verified table, so the rounding and
|
||||
-- the second source both go.
|
||||
--
|
||||
-- The loop starts at -1 like the reference's: `UIParent.lua` reads one index before the
|
||||
-- array there, and our binding answers Poor for it rather than reproducing that.
|
||||
-- The loop starts at -1 like the reference's, and the -1 row is not a curiosity: it is the
|
||||
-- colour a loot row wears while its item template is uncached, which is the value
|
||||
-- `GetLootSlotInfo` answers there. `GetItemQualityColor`'s clamp is UNSIGNED, so -1 lands
|
||||
-- on Common/white with 7-and-up (decision 1805, correcting 1199's "reads before the
|
||||
-- array").
|
||||
ITEM_QUALITY_COLORS = { }
|
||||
for i = -1, 6 do
|
||||
ITEM_QUALITY_COLORS[i] = { }
|
||||
|
||||
@@ -85,10 +85,13 @@
|
||||
LootFrame.xml's own "not implemented yet" caveat was written) — `$parentName`'s
|
||||
`inherits="GameFontNormalSmall"` below resolves for real; no stale caveat needed here.
|
||||
|
||||
IN-FLIGHT ITEMS: `GetLootRollItemInfo`'s `name`/`texture`/`quality` are `nil` while the item
|
||||
template query hasn't landed yet (`loot_roll.rs`). `GroupLootFrame_OnShow` falls back to
|
||||
`BENILLA_FALLBACK_ICON` / an empty name / common (quality 1) — the same fallback shape
|
||||
`LootFrame_Update` uses for an in-flight loot row.
|
||||
IN-FLIGHT ITEMS: `GetLootRollItemInfo`'s `name` and `texture` are `nil` while the item
|
||||
template query hasn't landed yet (`loot_roll.rs`); its `count` and `quality` are the
|
||||
reference's own miss tail, `1` and `1` (decision 1805 — 0x4c31a3), never nil. So the
|
||||
`or ITEM_QUALITY_COLORS[1]` below is dead weight rather than load-bearing, and the stock
|
||||
`GroupLootFrame_OnShow` this file shadows survives the same state without it. The
|
||||
`BENILLA_FALLBACK_ICON` / empty-name fallbacks are still doing work: the reference leaves
|
||||
both of those nil.
|
||||
|
||||
The Era API this drives (`benilla-ui/src/script/loot_roll.rs`, decision 0591):
|
||||
`GetLootRollItemInfo(rollID)` -> texture,name,count,quality,bindOnPickUp,itemID;
|
||||
|
||||
@@ -198,16 +198,16 @@
|
||||
-- moved it: it lived in our BuffFrame.xml, and the buff bar is the reference's own file
|
||||
-- now, which reads the global and declares nothing.
|
||||
--
|
||||
-- **`"1"` is a stated divergence, not the reference's default.** 0255 shipped the
|
||||
-- durations-shown geometry on the director's call, and 1139 turned it into a setting
|
||||
-- without changing what it ships as. This assignment is the ONLY statement of it: the
|
||||
-- saved chunk executes over it at load (1128), and the Interface page's row captures it as
|
||||
-- its Defaults value at OnLoad, which runs first — so Defaults honours the director's
|
||||
-- choice rather than the reference's table.
|
||||
-- **`"0"` — the reference's own, since 1804.** It shipped `"1"` from 0255, which put the
|
||||
-- durations-shown geometry in on the director's call, through 1139, which turned it into a
|
||||
-- setting without re-weighing what it ships as. This assignment is the ONLY statement of
|
||||
-- the default: the saved chunk executes over it at load (1128), and the Interface page's
|
||||
-- row captures it as its Defaults value at OnLoad, which runs first — so Defaults now
|
||||
-- walks back to the reference's table because this line IS the reference's table.
|
||||
--
|
||||
-- Read at CALL time by `BuffButton_Update` / `BuffButtons_UpdatePositions` /
|
||||
-- `BuffFrame_UpdateDuration`, so this file sitting far below the bar does not bind.
|
||||
SHOW_BUFF_DURATIONS = "1"
|
||||
SHOW_BUFF_DURATIONS = "0"
|
||||
RegisterForSave("SHOW_BUFF_DURATIONS")
|
||||
|
||||
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
|
||||
@@ -2183,8 +2183,9 @@
|
||||
base — the reference's own arrangement, and the reason
|
||||
the readout is raw yards here rather than a percent:
|
||||
"22" means the wheel stops at 22 yards, which is the
|
||||
thing being chosen. Our default is the factor fully
|
||||
raised (30 yd), where 1.12 ships 1.0. -->
|
||||
thing being chosen. The slider rests at 1.0 (15 yd),
|
||||
1.12's own registered default — it sat at the top of
|
||||
the range (30 yd) from 1140 until 1804. -->
|
||||
<!-- Camera Following Style (1493, values re-pinned by
|
||||
1502): 1.12's own dropdown
|
||||
(UIOptionsFrameCameraDropDown) in its own order —
|
||||
|
||||
@@ -62,10 +62,10 @@
|
||||
window opening/closing does.
|
||||
|
||||
Deviations (each cited again at its own widget below):
|
||||
- `QUEST_FADING_DISABLE` (the ref's "Instant Quest Text" option) DEFAULTS "1" where the ref
|
||||
defaults "0" — quest text lands whole and the panel fade-ins skip; see the uvar's own
|
||||
comment below. The write-on machinery itself (0132) is ref-verbatim and live behind the
|
||||
flag, and since decision 1136 the player can flip the flag on the Interface page.
|
||||
- `QUEST_FADING_DISABLE` (the ref's "Instant Quest Text" option) is no longer a deviation:
|
||||
1804 put it back on the reference's "0", so quest text writes on and the panels fade in
|
||||
out of the box. The write-on machinery (0132) is ref-verbatim, and since decision 1136
|
||||
the player can flip the flag on the Interface page.
|
||||
- The material swap is BUILT but always Parchment: `QuestFramePanelTemplate` carries the
|
||||
ref's four `$parentMaterial*` quadrants and `QuestFrame_SetMaterial` is the ref's own
|
||||
body, but `GetQuestBackgroundMaterial()` is not a surface this engine has, so
|
||||
@@ -94,22 +94,25 @@
|
||||
QUEST_DESCRIPTION_GRADIENT_CPS = 40
|
||||
QUESTINFO_FADE_IN = 1
|
||||
|
||||
-- "Instant Quest Text" (ref UIOptionsFrame.lua:20 — uvar QUEST_FADING_DISABLE, checkbox
|
||||
-- default "0"). Pinned "1" by direction (2026-07-17): text lands whole, the greeting/
|
||||
-- progress/reward fade-ins skip — and the quill still scratches ONCE, because that is
|
||||
-- what the ref's instant mode actually does (see the OnShow arm below): it jams the
|
||||
-- reveal edge past the text rather than skipping the write-on, so the first OnUpdate
|
||||
-- tick plays one WriteQuest before the gradient runs off. Instant is one scribble, not
|
||||
-- silence.
|
||||
-- "Instant Quest Text" (ref UIOptionsFrame.lua l.20/110 — uvar QUEST_FADING_DISABLE,
|
||||
-- both the checkbox table's `default` and `UIOptionsFrame_Init`'s assignment are "0").
|
||||
-- "0" is what we ship too, since 1804: the fade-in and the write-on are the reference's
|
||||
-- out-of-box quest text, and this was pinned "1" by direction on 2026-07-17 — a taste
|
||||
-- call made while the window was being built, never re-weighed as a *default*.
|
||||
--
|
||||
-- The ref home for this uvar is the options window, and that is where it lives now: the
|
||||
-- Flipped ON ("1"), text lands whole and the greeting/progress/reward fade-ins skip — and
|
||||
-- the quill still scratches ONCE, because that is what the ref's instant mode actually
|
||||
-- does (see the OnShow arm below): it jams the reveal edge past the text rather than
|
||||
-- skipping the write-on, so the first OnUpdate tick plays one WriteQuest before the
|
||||
-- gradient runs off. Instant is one scribble, not silence.
|
||||
--
|
||||
-- The ref home for this uvar is the options window, and that is where the row lives: the
|
||||
-- Interface page's "Instant Quest Text" row drives it (decision 1136), and this assignment
|
||||
-- is the only place the default is written down. Note what that buys — our default is "1"
|
||||
-- where 1.12's table says "0", and because the row CAPTURES its default here rather than
|
||||
-- restating one, the Defaults button walks the page back to the director's pin, not to the
|
||||
-- reference's. Registered for save on the line below, so a player who prefers the fade-in
|
||||
-- keeps it (1128's load order: this assignment, then the saved chunk, then consumers).
|
||||
QUEST_FADING_DISABLE = "1"
|
||||
-- is the only place the default is written down — the row CAPTURES it here rather than
|
||||
-- restating one, so the Defaults button now walks the page back to the reference's value
|
||||
-- because this line IS the reference's value. Registered for save on the line below
|
||||
-- (1128's load order: this assignment, then the saved chunk, then consumers).
|
||||
QUEST_FADING_DISABLE = "0"
|
||||
RegisterForSave("QUEST_FADING_DISABLE")
|
||||
|
||||
-- One quest-item row (kind = "choice"/"reward"/"required", 1-based `index`) — unchanged from
|
||||
|
||||
@@ -1440,7 +1440,7 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
// The shipped CVar table, before the realm below writes into it — the survey never registered
|
||||
// any, so `GetCVar` answered nil for every name the client actually ships. Same class as the
|
||||
// empty addon registry: a state the real client cannot be in.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
script.set_realm_name("Harness");
|
||||
// THE BIND POINT. `GetBindLocation()` answered `""` in every VM, and a logged-in character with
|
||||
// no hearth location is not a state one is in — the server sends `SMSG_BINDPOINTUPDATE` at
|
||||
|
||||
@@ -991,7 +991,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// The Audio page (0957): register the real CVar set first — the hermetic capture has
|
||||
// no CvarPlugin file load to race, and the rows must read real values, not the
|
||||
// nil-tolerant zeros — then open and select through the live paths.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
if let Err(e) =
|
||||
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowAudio:Click()")
|
||||
{
|
||||
@@ -1004,7 +1004,7 @@ pub(super) fn seed_ui_fixture(
|
||||
};
|
||||
// The Graphics page (0959), same posture as the Audio fixture: real CVar set, live
|
||||
// open-and-select paths.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
if let Err(e) =
|
||||
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowGraphics:Click()")
|
||||
{
|
||||
@@ -1019,7 +1019,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// open path. Its Remove Chat Hover Delay row reads a saved-variable global that
|
||||
// `ChatFrame.xml` declares at file scope, so a hermetic capture sees the shipped "0"
|
||||
// and the row paints unchecked — which is the shipped default, not a missing load.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
if let Err(e) =
|
||||
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowChat:Click()")
|
||||
{
|
||||
@@ -1051,7 +1051,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// posture as the page fixtures: real CVar set, the live open-select-toggle path. The
|
||||
// list's width settles from its OnUpdate a frame later (the kit's WIDTH SETTLE law) —
|
||||
// inside the capture's settle frames.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
if let Err(e) = script.run(
|
||||
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowControls:Click(); \
|
||||
OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()",
|
||||
@@ -1068,7 +1068,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// PostStartup seed isn't raced, the register_cvars precedent), then the live open
|
||||
// path; CVars registered too so the sibling category rows behave. Movement is
|
||||
// expanded so the lens sees both a header row and the byte-real default capsules.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
script.register_bindings(&crate::bindings::registry_commands());
|
||||
if let Err(e) = script.run(
|
||||
"ShowUIPanel(OptionsFrame); \
|
||||
@@ -1088,7 +1088,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// pull-in has its unit test). Focused (0989): captures pin the caret visible, so
|
||||
// this baseline also pins the caret hugging the text's end — the drawn-space
|
||||
// advance law's visual regression guard.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
if let Err(e) = script.run(
|
||||
"ShowUIPanel(OptionsFrame); OptionsFrameSearchBox:SetText(\"volume\"); OptionsFrameSearchBox:SetFocus()",
|
||||
)
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504 — wow-re's
|
||||
//! `chat-bubble.md` covers the spawn/geometry/anchor and never reached the manager tick.)
|
||||
//!
|
||||
//! Named divergences (all deliberate, decisions 0598/0599):
|
||||
//! - **`ChatBubblesParty` defaults ON** (binary default "0") — the director asked for `/p`
|
||||
//! bubbles; same posture as [`crate::vplates::VPlateMode`].
|
||||
//! Named divergences (all deliberate):
|
||||
//! - ~~**`ChatBubblesParty` defaults ON**~~ — **no longer a divergence (1804).** It shipped `"1"`
|
||||
//! against the binary's `"0"` from 0598, on the director's `/p` ask; both CVars now boot at the
|
||||
//! registrar's own values and the Chat page's row is the way back on. Same walk-back as
|
||||
//! [`crate::vplates::VPlateMode`], whose enemy plates were the same shape of pin.
|
||||
//! - **The v1 kind set is SAY/YELL/PARTY + monster say/yell.** The byte gate is
|
||||
//! "sender resolves", not a type whitelist, which *implies* guild/officer/whisper/emote
|
||||
//! bubbles too — but that category claim is INFERRED on an OPEN wire-type remap
|
||||
@@ -87,10 +89,11 @@ use benilla_world::view::WorldCamera;
|
||||
|
||||
/// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** —
|
||||
/// they were a pair of `const bool` from 0598 until the options window had a page to put
|
||||
/// them on, which is exactly the shape 1134 calls a row over a frozen gate. The defaults are the
|
||||
/// values that were frozen: `ChatBubbles` the reference's own "1"; `ChatBubblesParty` flipped ON
|
||||
/// against the binary's "0" (the director's ask: `/p` bubbles), the same deliberate deviation
|
||||
/// [`crate::vplates::VPlateMode`]'s boot default carries.
|
||||
/// them on, which is exactly the shape 1134 calls a row over a frozen gate.
|
||||
///
|
||||
/// **Both defaults are the binary's own** (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"` — wow-re
|
||||
/// `object-layer/scratch/chat-bubble.md`). `party` shipped ON from 0598 to 1804 on the director's
|
||||
/// `/p` ask; the row is on the Chat page, one click from where it was.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct BubbleConfig {
|
||||
/// `ChatBubbles` — say/yell and their monster variants.
|
||||
@@ -103,7 +106,7 @@ impl Default for BubbleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
all: true,
|
||||
party: true,
|
||||
party: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -863,20 +866,30 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The v1 kind set: say/yell + monster say/yell on `ChatBubbles`, party on
|
||||
/// `ChatBubblesParty` (benilla default ON — the 0598 deviation); everything else out
|
||||
/// pending the OPEN remap capture. And the two switches are genuinely separate: the client
|
||||
/// gates party lines on their own CVar, so turning one off leaves the other bubbling.
|
||||
/// `ChatBubblesParty`; everything else out pending the OPEN remap capture. And the two
|
||||
/// switches are genuinely separate: the client gates party lines on their own CVar, so
|
||||
/// turning one off leaves the other bubbling.
|
||||
///
|
||||
/// The sweep runs on a config with **both switches planted on**, which is not what a fresh
|
||||
/// client boots at: `ChatBubblesParty` shipped `"1"` from 0598's `/p` ask until 1804 put both
|
||||
/// defaults back on the binary's (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"`). Planted,
|
||||
/// because the question here is which *kinds* the gate admits, and a kind whose switch is off
|
||||
/// cannot answer it. The shipped pair is asserted below, where it is exactly the
|
||||
/// party-off half of the independence check.
|
||||
#[test]
|
||||
fn the_kind_set_is_the_uncontested_v1() {
|
||||
use ChatEventKind as K;
|
||||
let on = BubbleConfig::default();
|
||||
let on = BubbleConfig {
|
||||
all: true,
|
||||
party: true,
|
||||
};
|
||||
for k in [K::Say, K::Yell, K::MonsterSay, K::MonsterYell] {
|
||||
assert_eq!(bubble_cvar(k, &on), Some(true));
|
||||
}
|
||||
assert_eq!(
|
||||
bubble_cvar(K::Party, &on),
|
||||
Some(true),
|
||||
"the director's /p ask"
|
||||
"party on its own CVar"
|
||||
);
|
||||
for k in [
|
||||
K::Guild,
|
||||
@@ -891,11 +904,15 @@ mod tests {
|
||||
assert_eq!(bubble_cvar(k, &on), None, "{k:?} must not bubble in v1");
|
||||
}
|
||||
|
||||
let no_party = BubbleConfig {
|
||||
all: true,
|
||||
party: false,
|
||||
};
|
||||
assert_eq!(bubble_cvar(K::Party, &no_party), Some(false));
|
||||
// Party off with say on — which is the SHIPPED pair since 1804, so this half of the
|
||||
// independence check is [`BubbleConfig::default`] itself and pins the boot state too.
|
||||
let no_party = BubbleConfig::default();
|
||||
assert!(no_party.all && !no_party.party, "the shipped pair");
|
||||
assert_eq!(
|
||||
bubble_cvar(K::Party, &no_party),
|
||||
Some(false),
|
||||
"/p does not bubble out of the box"
|
||||
);
|
||||
assert_eq!(
|
||||
bubble_cvar(K::Say, &no_party),
|
||||
Some(true),
|
||||
|
||||
+507
-117
@@ -4,10 +4,23 @@
|
||||
//!
|
||||
//! - **The registered set** ([`REGISTERED`]): only vars something actually reads — a host knob,
|
||||
//! or (since 1140) a live Lua consumer, which is the same rule seen from the UI side and the
|
||||
//! only refinement the honest-tree law has needed. Defaults are the code's own truths — the sound quartet is the client's
|
||||
//! verified CVar registration defaults (wow-re `benilla-pins.md` B10, quoted in
|
||||
//! [`crate::sound::SoundConfig`]), `uiScale`/`farclip` are benilla's shipped defaults —
|
||||
//! and a test welds each string to the constant it mirrors so they cannot drift.
|
||||
//! only refinement the honest-tree law has needed.
|
||||
//!
|
||||
//! **A row's default is the REFERENCE's default** (decision 1804), and every row says where it
|
||||
//! stands against it — that is [`Registered::reference`], a mandatory third column with no
|
||||
//! "unknown" variant, so a new row cannot be added without answering the question. Two tests
|
||||
//! hold it: one checks each row's claim in both directions (a `Same` that drifted, *and* a
|
||||
//! `Deviates` that quietly came back into agreement), the other pins the deviation set as a
|
||||
//! readable list. Beside that, and unchanged, each default is still **welded to the code
|
||||
//! constant it mirrors** — `SoundConfig`, `NameConfig`, `ZoomLimit` and the rest — so the CVar
|
||||
//! table and the knob it feeds cannot drift apart either.
|
||||
//!
|
||||
//! **The reference column has one source**: wow-re's
|
||||
//! `system/cvar/scratch/registered-defaults-census.md` and the regenerable manifest beside it,
|
||||
//! `re/cvar/cvar-register-sites.tsv` — all 214 of the reference's `CVar::Register` sites with
|
||||
//! name, help, flags, default string, callback, category and record global. 1804 dispatched the
|
||||
//! §5 round that built it; a new row looks its answer up there rather than re-deriving it, and a
|
||||
//! row that disagrees with it is a contradiction to resolve before it lands (`method.md`).
|
||||
//! - **Boot**: read `benilla-config/config.toml` ([`crate::local_state`]) and apply it to the knob
|
||||
//! resources; when the UI VM exists, register the table and push the resolved session values
|
||||
//! so `GetCVar` answers what the client is actually doing.
|
||||
@@ -47,9 +60,141 @@ use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
|
||||
use benilla_world::clutter::ClutterConfig;
|
||||
use benilla_world::view::{MsaaSetting, ViewDistance, FARCLIP_RANGE, MSAA_RANGE};
|
||||
|
||||
/// The host-backed CVars: `(registered name, default)`. Grows one row per knob a settings page
|
||||
/// actually wires — never ahead of the knob (see the module doc).
|
||||
pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
/// One host-backed CVar: its registered name, benilla's shipped default, and — the column that
|
||||
/// exists so a divergence is a *decision* rather than an accident — **what the reference ships**
|
||||
/// ([`Reference`]).
|
||||
///
|
||||
/// **The standard this table encodes: a benilla option's default IS the reference's.** Shipping
|
||||
/// something else is allowed and sometimes right, but it costs a [`Reference::Deviates`] row
|
||||
/// naming the reference's own value and the reason. `Reference` has no `Default` and no "unknown"
|
||||
/// variant, so adding a row means answering the question; and because [`Reference::Same`] and
|
||||
/// [`Reference::Deviates`] both carry the reference's value, the test
|
||||
/// [`tests::defaults_stand_where_the_reference_column_says`] checks the claim in both directions —
|
||||
/// a `Same` row that stopped matching fails, and so does a `Deviates` row that has quietly come
|
||||
/// back into agreement.
|
||||
pub(crate) struct Registered {
|
||||
/// The registered name, in the reference's own spelling.
|
||||
pub(crate) name: &'static str,
|
||||
/// What a fresh `benilla-config` runs at — seeded into the knob, and what `GetCVar` answers
|
||||
/// until the player moves it.
|
||||
pub(crate) default: &'static str,
|
||||
/// Where that value stands against the reference's.
|
||||
///
|
||||
/// `#[allow(dead_code)]` because this column's readers are a **human** and
|
||||
/// [`tests::defaults_stand_where_the_reference_column_says`] — nothing at runtime consults
|
||||
/// it, and nothing should: it records what the *reference* does, which is an input to the
|
||||
/// choice above it, never a value this client acts on.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) reference: Reference,
|
||||
}
|
||||
|
||||
/// benilla's default, weighed against the reference's own.
|
||||
///
|
||||
/// **What "the reference's default" means here** is the **registered factory default** — the
|
||||
/// string the real client's `CVar::Register` (`0x63db90`) call passes for that name, byte-read —
|
||||
/// or, for a setting 1.12 keeps FrameXML-side instead of as a CVar, the value
|
||||
/// `UIOptionsFrame.lua` boots it at. Two readings it deliberately is **not**, both of which have
|
||||
/// misled a reader before:
|
||||
///
|
||||
/// - **Not what the reference install's `Config.wtf` says.** That file is one player's saved
|
||||
/// *diff*: `SaveConfig 0x63d980` writes only what has moved off its default, so a line's mere
|
||||
/// presence is proof the registered default is something *else* (wow-re
|
||||
/// `cvar/scratch/graphics-cost-cvar-census.md` §10 — the trap it exists to close).
|
||||
/// - **Not, by itself, what a fresh install ends up running at.** `hwDetect` rewrites sixteen
|
||||
/// video CVars out of `VideoHardware.dbc` before the first frame, and the `useUiScale`-OFF leg
|
||||
/// computes a UI scale of its own. Where the client's own boot code overrides the registered
|
||||
/// string like that, benilla follows the *behaviour* and the row says so — that is
|
||||
/// [`Reference::Overridden`], not a deviation.
|
||||
///
|
||||
/// Every row's provenance — the register-site VA, or the FrameXML line — belongs in the comment
|
||||
/// above it. That is not decoration: it is what lets the next reader re-check the claim instead
|
||||
/// of trusting this table.
|
||||
#[allow(dead_code)] // read by a human and by the test — see `Registered::reference`
|
||||
pub(crate) enum Reference {
|
||||
/// The reference registers this exact default string, and benilla ships it too. The copy is
|
||||
/// deliberate: the test compares the two, so an edit to `default` that forgets the reference
|
||||
/// fails here rather than shipping.
|
||||
Same(&'static str),
|
||||
/// The reference *registers* `registered`, but its own boot code overwrites that before the
|
||||
/// first frame, and benilla's `default` is what that code lands on — faithful to the client's
|
||||
/// behaviour, which is what the standard asks for. `why` is the override.
|
||||
Overridden {
|
||||
registered: &'static str,
|
||||
why: &'static str,
|
||||
},
|
||||
/// The reference ships `value`; benilla knowingly ships something else. `why` is the reason
|
||||
/// and the decision that ruled it. **This is the variant that answers "where do we differ?"**
|
||||
/// — every row here is a standing choice somebody made, reviewable as a list.
|
||||
Deviates {
|
||||
value: &'static str,
|
||||
why: &'static str,
|
||||
},
|
||||
/// The reference has no such setting to match: benilla's own knob (`renderScale`), or an era
|
||||
/// name for something 1.12 never made settable. `why` says which — and, where the reference
|
||||
/// still *behaves* some way, what that behaviour is.
|
||||
Ours(&'static str),
|
||||
}
|
||||
|
||||
/// A row whose default is the reference's own registered string.
|
||||
const fn same(name: &'static str, default: &'static str) -> Registered {
|
||||
Registered {
|
||||
name,
|
||||
default,
|
||||
reference: Reference::Same(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that follows the reference's *behaviour* where its own boot code overrides the
|
||||
/// registered string — see [`Reference::Overridden`].
|
||||
const fn overridden(
|
||||
name: &'static str,
|
||||
default: &'static str,
|
||||
registered: &'static str,
|
||||
why: &'static str,
|
||||
) -> Registered {
|
||||
Registered {
|
||||
name,
|
||||
default,
|
||||
reference: Reference::Overridden { registered, why },
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that knowingly leaves the reference's default — `value` is the reference's, `why` the
|
||||
/// recorded reason.
|
||||
const fn deviates(
|
||||
name: &'static str,
|
||||
default: &'static str,
|
||||
value: &'static str,
|
||||
why: &'static str,
|
||||
) -> Registered {
|
||||
Registered {
|
||||
name,
|
||||
default,
|
||||
reference: Reference::Deviates { value, why },
|
||||
}
|
||||
}
|
||||
|
||||
/// A row the reference has no counterpart for.
|
||||
const fn ours(name: &'static str, default: &'static str, why: &'static str) -> Registered {
|
||||
Registered {
|
||||
name,
|
||||
default,
|
||||
reference: Reference::Ours(why),
|
||||
}
|
||||
}
|
||||
|
||||
/// The table as the script VM's registrar wants it — `(name, default)` pairs, in table order.
|
||||
///
|
||||
/// The registrar has no use for the [`Reference`] column: that column is for *us* (and for the
|
||||
/// test that holds the standard), never for the engine.
|
||||
pub(crate) fn registered_pairs() -> impl Iterator<Item = (&'static str, &'static str)> {
|
||||
REGISTERED.iter().map(|r| (r.name, r.default))
|
||||
}
|
||||
|
||||
/// The host-backed CVars. Grows one row per knob a settings page actually wires — never ahead of
|
||||
/// the knob (see the module doc) — and every row states where its default stands against the
|
||||
/// reference's ([`Registered`]).
|
||||
pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// The realm the session is on — a REAL 1.12 CVar (`0x83f2d0`, persisted, wow-re
|
||||
// `savedvariables-protocol.md`: the client builds its SavedVariables path from it), and a live
|
||||
// Lua consumer in the strongest sense the honest-tree rule asks for. `Ace/AceState.lua:27` does
|
||||
@@ -63,195 +208,287 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// `"Last realm connected to"` beside the registration, but that reads like the CVar's HELP text
|
||||
// rather than its value and nothing here needs to resolve it — `""` is what `ace.trim` handles
|
||||
// cleanly, and inventing a realm name would be worse than admitting we have none yet.
|
||||
("realmName", ""),
|
||||
same("realmName", ""),
|
||||
// The address of the logon server — the reference's own CVar, byte-verified in `WoW.exe`
|
||||
// (the registration's string neighbours are `realmlist.wtf`, "Address of realm list server"
|
||||
// and `us.logon.worldofwarcraft.com:3724`; wow-re `mpq/scratch/startup-order-A.md` row 62).
|
||||
// A **string** row, so it is matched ahead of the numeric parse in `apply_to_knobs`.
|
||||
// The default diverges knowingly — see `realmlist::DEFAULT_REALMLIST`.
|
||||
(
|
||||
deviates(
|
||||
crate::realmlist::CVAR_REALMLIST,
|
||||
crate::realmlist::DEFAULT_REALMLIST,
|
||||
"us.logon.worldofwarcraft.com:3724",
|
||||
"1667: that host has not resolved since 2019, so shipping it makes every first launch a \
|
||||
DNS failure; benilla dials the machine it is running on",
|
||||
),
|
||||
("MasterVolume", "1"),
|
||||
("SoundVolume", "1"),
|
||||
("MusicVolume", "0.4"),
|
||||
("AmbienceVolume", "0.6"),
|
||||
same("MasterVolume", "1"),
|
||||
same("SoundVolume", "1"),
|
||||
same("MusicVolume", "0.4"),
|
||||
same("AmbienceVolume", "0.6"),
|
||||
// The three 1.12 sound enables (registrar defaults all "1", wow-re B10):
|
||||
// `MasterSoundEffects` is the MASTER "Enable All Sound" checkbox (SoundOptionsFrame.lua
|
||||
// index 1 — its callback sets the engine-wide pause flag), NOT an SFX-only toggle; 1.12
|
||||
// has no `EnableSound`/`EnableSFX` at all.
|
||||
("MasterSoundEffects", "1"),
|
||||
("EnableMusic", "1"),
|
||||
("EnableAmbience", "1"),
|
||||
same("MasterSoundEffects", "1"),
|
||||
same("EnableMusic", "1"),
|
||||
same("EnableAmbience", "1"),
|
||||
// Zone reverb (1153). The binary registers this one `"1"` (`0x4573be`) and we register it
|
||||
// `"0"` — the only row here that knowingly leaves the registrar's default, because the
|
||||
// reference's reverb is EAX-over-hardware and that hardware has not existed since Vista:
|
||||
// `"1"` would ship audio the real client has never actually produced (bug B236).
|
||||
// `SoundConfig::reverb` carries the evidence.
|
||||
("SoundReverb", "0"),
|
||||
deviates(
|
||||
"SoundReverb",
|
||||
"0",
|
||||
"1",
|
||||
"1153: the reference's reverb is EAX-over-hardware and that hardware has not existed \
|
||||
since Vista, so \"1\" would ship audio the real client has never actually produced \
|
||||
(B236)",
|
||||
),
|
||||
// The output limiter (1551) — benilla's own, not a 1.12 CVar. The reference needs no such DSP
|
||||
// (its mix is FMOD 3's and its headroom lives in the SFX-bus auto-duck); benilla sums into f32
|
||||
// behind a hard clamp, and every WoW SFX is mastered to full scale, so two overlapping kits
|
||||
// clip. Registered so the fix can be A/B'd live against the defect it fixes.
|
||||
("SoundOutputLimiter", "1"),
|
||||
("uiScale", "0.9"),
|
||||
("farclip", "350"),
|
||||
ours(
|
||||
"SoundOutputLimiter",
|
||||
"1",
|
||||
"1551: benilla's own — the reference's FMOD 3 mix needs no such DSP; we sum into f32 \
|
||||
behind a hard clamp, and every WoW SFX is mastered to full scale",
|
||||
),
|
||||
overridden(
|
||||
"uiScale",
|
||||
"0.9",
|
||||
"1.0",
|
||||
"a fresh reference client never consults this CVar: `useUiScale` registers \"0\" \
|
||||
(`0x48fce4`), and the OFF leg `0x492f70` computes clamp(768/height, 0.9, 1.0) instead — \
|
||||
0.9 at 854 px tall and up, which is every window we ship against. It is 1.0 at 768 and \
|
||||
below, where our flat 0.9 does diverge; `ui_script::DEFAULT_UI_SCALE` carries that. \
|
||||
`useUiScale` itself has no row here: nothing reads it, and registering it would only \
|
||||
offer a switch whose ON path we do not implement",
|
||||
),
|
||||
same("farclip", "350"),
|
||||
// The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own
|
||||
// Interface Options CVars (UIOptionsFrame.lua indices 45/1); their defaults are the
|
||||
// reference behaviors benilla already shipped (empty-world click clears the target; no
|
||||
// pitch invert). `autoLootDefault` is era's — no 1.12 CVar exists, vanilla only had the
|
||||
// shift gesture — default off, like era's engine registrar.
|
||||
("deselectOnClick", "1"),
|
||||
same("deselectOnClick", "1"),
|
||||
// *Block Trades* (decision 1764) — 1.12's own `BlockTrades` (`0x842fbc`), the General-box
|
||||
// checkbox at index 14 whose tooltip is "Block all incoming trade requests.". Registered
|
||||
// **"0"**: the reference's own `0x4bf7bc` leg only refuses when the CVar is set, so an
|
||||
// unset/absent value has to mean "trades allowed" — and a client that shipped with trades
|
||||
// blocked would refuse every trade until the player found the box. The knob is
|
||||
// [`crate::ui_trade::BlockTrades`], read by the incoming-request answerer.
|
||||
("BlockTrades", "0"),
|
||||
// 1.12's own `autoSelfCast` (`0x870dc0`) — a friendly cast that binds nothing falls back to
|
||||
// the caster. The behaviour has been here since the cast arm landed, welded to a Resource
|
||||
// default; it is a CVar now because 1.12's `TOGGLEAUTOSELFCAST` binding is `GetCVar`/`SetCVar`
|
||||
// over this exact name and there was nothing for it to toggle (decision 1745).
|
||||
same("BlockTrades", "0"),
|
||||
// 1.12's own `autoSelfCast` — a friendly cast that binds nothing falls back to the caster.
|
||||
// The behaviour has been here since the cast arm landed, welded to a Resource default; it is a
|
||||
// CVar now because 1.12's `TOGGLEAUTOSELFCAST` binding is `GetCVar`/`SetCVar` over this exact
|
||||
// name and there was nothing for it to toggle (decision 1745).
|
||||
//
|
||||
// Register site `0x6e731d`, default string `"0"`, record `[0xceac34]`, one reader at
|
||||
// `0x6e53d7` (wow-re `cvar/scratch/registered-defaults-census.md`, 1804's §5 round). **This
|
||||
// row used to cite `0x870dc0` as the record; that is the NAME string** — corrected there.
|
||||
//
|
||||
// **benilla ships it ON and the reference registers "0"** — a named deviation that predates
|
||||
// this row (`cast_target::AutoSelfCast`): with it off, an unbindable friendly cast falls into
|
||||
// the reference's targeting-cursor machine, which is unmodeled, leaving no path at all. Flip
|
||||
// to the reference's default when that machine lands.
|
||||
("autoSelfCast", "1"),
|
||||
deviates(
|
||||
"autoSelfCast",
|
||||
"1",
|
||||
"0",
|
||||
"1745: with it off, an unbindable friendly cast falls into the reference's \
|
||||
targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \
|
||||
machine lands",
|
||||
),
|
||||
// The five saved camera views and the live index (decision 1745) — the reference's own
|
||||
// sixteen names and its own shipped default strings, both read out of `WoW.exe` and owned by
|
||||
// [`crate::player::camera_view`], which is also the only writer. Registered here so they are
|
||||
// ordinary CVars: persisted as a diff like everything else, readable from a macro, and
|
||||
// reachable by `SetCVar` — which is what makes a `SaveView` survive a restart.
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::CVAR_ACTIVE_VIEW,
|
||||
crate::player::camera_view::ACTIVE_VIEW_DEFAULT,
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[0][0],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[0][0],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[0][1],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[0][1],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[0][2],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[0][2],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[1][0],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[1][0],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[1][1],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[1][1],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[1][2],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[1][2],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[2][0],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[2][0],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[2][1],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[2][1],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[2][2],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[2][2],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[3][0],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[3][0],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[3][1],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[3][1],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[3][2],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[3][2],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[4][0],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[4][0],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[4][1],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[4][1],
|
||||
),
|
||||
(
|
||||
same(
|
||||
crate::player::camera_view::VIEW_CVARS[4][2],
|
||||
crate::player::camera_view::VIEW_DEFAULTS[4][2],
|
||||
),
|
||||
("mouseInvertPitch", "0"),
|
||||
("autoLootDefault", "0"),
|
||||
same("mouseInvertPitch", "0"),
|
||||
ours(
|
||||
"autoLootDefault",
|
||||
"0",
|
||||
"0961: 1.12 has no auto-loot CVar at all — vanilla offers only the shift gesture, so OFF \
|
||||
IS the reference's own behaviour; the spelling is era's",
|
||||
),
|
||||
// The overhead-name trio (0992): 1.12's own UnitName* CVars (UIOptionsFrame.lua indices
|
||||
// 21/30/67) over the nameplates module's gates. Defaults mirror NameConfig::default() —
|
||||
// npc/own ON are director directives, diverging from the binary's "0" defaults on purpose
|
||||
// (the divergence and its dates live on NameConfig's doc).
|
||||
("UnitNamePlayer", "1"),
|
||||
("UnitNameNPC", "1"),
|
||||
("UnitNameOwn", "1"),
|
||||
// 21/30/67) over the nameplates module's gates. Defaults mirror `NameConfig::default()` and
|
||||
// are the binary's own, byte-read at the `0x6c7470` registrar (wow-re
|
||||
// `object-layer/scratch/overhead-name.md`, name string / default string per row, folded into
|
||||
// mask `0xce8720`) — `UnitNamePlayer` `0x86c694` → `"1"` `0x82e748`, `UnitNameNPC` `0x86c6a4`
|
||||
// and `UnitNameOwn` `0x86c6b0` → `"0"` `0x82e570`. Corroborated the other way by the
|
||||
// reference install's own `Config.wtf`, which carries `SET UnitNameNPC "1"` and
|
||||
// `SET UnitNameOwn "1"`: `SaveConfig 0x63d980` writes only what has MOVED off its default, so
|
||||
// those two lines existing is proof the defaults are not "1".
|
||||
//
|
||||
// **npc and own shipped ON here from 2026-07-12 until 1804** — see `NameConfig`'s doc.
|
||||
same("UnitNamePlayer", "1"),
|
||||
same("UnitNameNPC", "0"),
|
||||
same("UnitNameOwn", "0"),
|
||||
// The two V-plate toggles over `VPlateMode` — the engine bitmask `[0xc4da34]`'s bit 0 and
|
||||
// bit 3. 1.12 registers NO nameplate CVar (wow-re, VERIFIED — the bitmask is a plain runtime
|
||||
// global, persisted FrameXML-side as the `RegisterForSave`'d `NAMEPLATES_ON` /
|
||||
// `FRIENDNAMEPLATES_ON`), so these take the LATER-era engine's names: the `autoLootDefault`
|
||||
// posture, where benilla's persistence IS the CVar store (0954) and a setting with no 1.12
|
||||
// CVar gets the era spelling rather than an invented one. Defaults mirror
|
||||
// `VPlateMode::default()` — enemy ON is the 0167 director call, friendly OFF is faithful.
|
||||
(crate::vplates::CVAR_ENEMIES, "1"),
|
||||
(crate::vplates::CVAR_FRIENDS, "0"),
|
||||
// World detail (0992): 1.12's video-panel var (the ENVIRONMENT_DETAIL slider, 0..2) over
|
||||
// the clutter-density knob — 0 is the client's bare frillDensity baseline (×1 = 16 visits),
|
||||
// each step +1×, so 0/1/2 are the 16/32/48 `SetWorldDetail` itself writes.
|
||||
// CVar gets the era spelling rather than an invented one.
|
||||
//
|
||||
// **"1", not "2" (1649).** This shipped at High because that is the panel's top stop, not
|
||||
// because the reference runs there. It does not: `frillDensity` registers at **16**, and on a
|
||||
// first launch `hwDetect` overwrites it from `VideoHardware.dbc` — **24** on any D3D9-class
|
||||
// part (fallback row 170) and **8** on the weakest (row 168). Both sit BELOW this panel's
|
||||
// Medium, and the fresh-install 24 is not on a stop at all: the reference's own slider cannot
|
||||
// express what its hardware detection chose. So every stop we could pick is a divergence, and
|
||||
// High was the most expensive one available — 3x the registered default and 2x what a fresh
|
||||
// install actually draws. Medium is the nearest stop that is still no sparser than the
|
||||
// reference's own fresh install, which is the side to err on for a knob about ground cover.
|
||||
("WorldDetail", "1"),
|
||||
// Mouse Sensitivity (1140): 1.12's own `mousespeed` slider (UIOptionsFrameSliders, 0.5..1.5
|
||||
// step 0.05), a MULTIPLIER over the camera's own per-pixel rate — which was a frozen constant
|
||||
// until this row. Default "1" is the shipped feel exactly, welded to LookConfig::default().
|
||||
("mousespeed", "1"),
|
||||
// **`same`, not `ours`, and that distinction is the point**: the reference has no CVar to
|
||||
// match, but it very much has a *setting* to match, and it boots both halves OFF —
|
||||
// `UIOptionsFrame_Init` assigns `NAMEPLATES_ON = nil` / `FRIENDNAMEPLATES_ON = nil` and
|
||||
// `OptionsFrame_ApplySavedSettings` only calls `ShowNameplates()` on a truthy SAVED value
|
||||
// (the install's `Interface\FrameXML\UIOptionsFrame.lua` l.180-183 / l.769-775). A fresh
|
||||
// 1.12 client draws no plates until V is pressed. Enemy plates shipped ON here from 0167
|
||||
// until 1804 — `VPlateMode::default()` carries that history.
|
||||
same(crate::vplates::CVAR_ENEMIES, "0"),
|
||||
same(crate::vplates::CVAR_FRIENDS, "0"),
|
||||
// World detail (0992) — the ENVIRONMENT_DETAIL slider's 0..2, over the clutter-density knob.
|
||||
// 0 is the client's bare `frillDensity` baseline (×1 = 16 visits), each step +1×, so 0/1/2 are
|
||||
// the 16/32/48 `SetWorldDetail` itself writes.
|
||||
//
|
||||
// **Not a 1.12 CVar** (corrected 1804 — this row used to call it "1.12's video-panel var").
|
||||
// `WorldDetail` does not exist as a string in `WoW.exe` (scanned; positive control
|
||||
// `frillDensity` present), and `OptionsFrame.lua:27`'s `func = "WorldDetail"` is a *function
|
||||
// name suffix*: the slider calls the engine's `GetWorldDetail`/`SetWorldDetail`. So the
|
||||
// spelling is the API's — the `autoLootDefault` posture.
|
||||
//
|
||||
// **"1" IS the reference's own setting, and getting there took two goes.** `SetWorldDetail
|
||||
// 0x488dd0` writes **two** CVars per stop: `frillDensity` {16,32,48} and `SmallCull`
|
||||
// {0.07,0.04,0.01}. The registered pair is `frillDensity` **16** (stop 0) with `SmallCull`
|
||||
// **0.04** (stop **1**) — the reference boots an inconsistent pair — and `GetWorldDetail`
|
||||
// reads only `SmallCull`. So its own slider **reads Medium at boot**, which is this row.
|
||||
// Mid-1804 this was filed as a deviation against "0" on two true facts that are not the
|
||||
// answer: `OptionsFrame.lua:430`'s Defaults ladder yields 0, and `frillDensity` registers 16.
|
||||
// A partly-verified mechanism is not the mechanism (wow-re
|
||||
// `cvar/scratch/registered-defaults-census.md`, the §5 round 1804 dispatched).
|
||||
//
|
||||
// What IS still divergent is the grass, and that is a *mapping* difference rather than a
|
||||
// default: our stop 1 scatters ×2 (32) where the reference's boot `frillDensity` is 16
|
||||
// registered, 24 after `hwDetect` reads `VideoHardware.dbc` (row 170 on any D3D9-class part,
|
||||
// 8 on the weakest). 24 is on no stop of ours; 1649 broke that tie toward the denser stop,
|
||||
// because erring sparse is the worse failure for a knob about ground cover.
|
||||
same("WorldDetail", "1"),
|
||||
// Mouse Sensitivity (1140): 1.12's own MOUSE_SENSITIVITY slider (`UIOptionsFrameSliders` row
|
||||
// 1, 0.5..1.5 step 0.05), a MULTIPLIER over the camera's own per-pixel rate — which was a
|
||||
// frozen constant until this row. Default "1" is the shipped feel exactly, welded to
|
||||
// `LookConfig::default()`.
|
||||
//
|
||||
// **The spelling is FrameXML's, not the binary's**, and that is deliberate: `WoW.exe` holds
|
||||
// `mouseSpeed` (capital S, register site `0x402c7b`) while `UIOptionsFrame.lua`'s slider
|
||||
// writes `cvar = "mousespeed"`. The reference reconciles them by looking CVars up
|
||||
// case-insensitively (`SStrCmpI`, wow-re `cvar/cvar.md`), and so do we (`apply_to_knobs`
|
||||
// lowercases first), so both spellings answer. We take the one the interface uses, because
|
||||
// that is the one an addon will type.
|
||||
//
|
||||
// **The VALUE agrees and the MECHANISM does not** (wow-re
|
||||
// `cvar/scratch/registered-defaults-census.md` §, 1804). The reference's default is not a
|
||||
// literal at all: it is `sprintf("%1.1f", SPI_GETMOUSESPEED × 0.1)`, which is `"1.0"` on a
|
||||
// stock Windows host — so "1" is the right number. But its record has **zero readers**: the
|
||||
// slider drives the *operating system's* pointer speed through `SPI_SETMOUSESPEED` (clamped
|
||||
// [0.1, 2.0]), not an in-engine gain. benilla will not reach out and repoint the OS mouse, so
|
||||
// ours is a multiplier over our own per-pixel rate — the same dial, the same range, the same
|
||||
// resting value, a different thing underneath. Recorded here rather than filed as a deviation
|
||||
// because the default is the question this table answers, and the default matches.
|
||||
same("mousespeed", "1"),
|
||||
// Max Camera Distance (1140): 1.12's `cameraDistanceMaxFactor` (its MAX_FOLLOW_DIST slider,
|
||||
// 1..2 step 0.1) over `cameraDistanceMax`'s 15 yd base. Registered "2" — the factor fully
|
||||
// raised — because that IS benilla's shipped 30 yd ceiling, a knowing divergence from the
|
||||
// reference's registrar "1" that camera.rs has carried in prose since it was written.
|
||||
("cameraDistanceMaxFactor", "2"),
|
||||
// 1..2 step 0.1) over `cameraDistanceMax`'s 15 yd base. **"1", the reference's registrar
|
||||
// value** (wow-re `ui/scratch/follow-camera.md`: "cameraDistanceMax 15.0,
|
||||
// cameraDistanceMaxFactor 1.0") — so the shipped ceiling is 15 yd, not the 30 this row
|
||||
// registered from 1140 until 1804. `ZoomLimit`'s doc carries why that changed; the slider
|
||||
// still reaches 2.
|
||||
same("cameraDistanceMaxFactor", "1"),
|
||||
// Camera Following Style (1493, re-pinned by 1502): 1.12's `cameraSmoothStyle` — the
|
||||
// auto-return that swings the camera back behind the character. Registered "1" = Smart, which
|
||||
// is BOTH the reference's registrar default (byte-verified: the argument is loaded from
|
||||
// `[0x84f4f4]` -> "1" at the `0x50ba92` register site) and the director's call; benilla behaved
|
||||
// as Never unconditionally until this row. The enum is the ENGINE's — 0 Never, 1 Smart,
|
||||
// 2 Always — NOT the 1/2/3 the reference's own dropdown writes; see `FollowStyle`.
|
||||
("cameraSmoothStyle", "1"),
|
||||
same("cameraSmoothStyle", "1"),
|
||||
// Its sibling selector (1502), also registered "1": the reference reads THIS style instead
|
||||
// whenever the state mask contains Track or Fear — the externally-driven states — indexing the
|
||||
// same matrices. No row on any 1.12 panel, here or there; the reader is the host.
|
||||
("cameraSmoothTrackingStyle", "1"),
|
||||
same("cameraSmoothTrackingStyle", "1"),
|
||||
// The auto-follow's rate (1502), °/s — 1.12's own AUTO_FOLLOW_SPEED slider
|
||||
// (`UIOptionsFrameSliders`, 90..270 by 10), registered at the binary's "180.0" (`[0xbe1070]`).
|
||||
// It sets the transition's DURATION (`|dyaw| / rate * factor`), so it is an average rate, not a
|
||||
// slew. No row yet — the slider is a one-line follow-on now that the knob exists.
|
||||
("cameraYawSmoothSpeed", "180"),
|
||||
same("cameraYawSmoothSpeed", "180"),
|
||||
// Status Text (1140): 1.12's `statusBarText`, the "always show value / max on a status bar"
|
||||
// switch. **No host knob** — its consumer is Lua (TextStatusBar.xml, decision 1082, which was
|
||||
// written waiting for this key and reads it on every repaint). Default "0": the reference's
|
||||
// out-of-box look is hover-only numerals. That default is BEHAVIOUR-derived, not byte-read —
|
||||
// 1.12's registrar value for this var is not pinned in wow-re yet.
|
||||
("statusBarText", "0"),
|
||||
// out-of-box look is hover-only numerals.
|
||||
//
|
||||
// **Byte-read since 1804** — register site `0x48fc34`, default string `"0"`, record
|
||||
// `[0xb4d904]`, and a whole-image census finds that record has **no engine reader at all**:
|
||||
// this CVar is FrameXML's alone, which is exactly the shape this row was built for (wow-re
|
||||
// `cvar/scratch/registered-defaults-census.md`). It used to concede "behaviour-derived, not
|
||||
// byte-read"; that hedge is retired.
|
||||
same("statusBarText", "0"),
|
||||
// Enhanced Tooltips (B230): 1.12's `UberTooltips`, the *Enhanced Tooltips* checkbox
|
||||
// (`UIOptionsFrame.lua:15`, `USE_UBERTOOLTIPS`). **No host knob** — its consumers are Lua, and
|
||||
// there are three: PetActionBar.xml forks the whole tooltip on it (a token's own text with the
|
||||
@@ -261,13 +498,14 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// confirming the layout. Those three Lua sites each carried the reference's fork in prose and
|
||||
// then collapsed it to this default, on the stated premise that benilla shipped no CVar state
|
||||
// for anything to move. That premise expired with 0954, and this row is what un-collapses them.
|
||||
("UberTooltips", "1"),
|
||||
same("UberTooltips", "1"),
|
||||
// The two chat-bubble switches (1139): 1.12's own registrar CVars over the bubble gate,
|
||||
// which held them as `const bool` from 0598 until this window had a page for them.
|
||||
// `ChatBubbles` is the reference's registered "1"; `ChatBubblesParty` is ON where the binary
|
||||
// registers "0" — the director's `/p` ask, mirrored from BubbleConfig::default().
|
||||
("ChatBubbles", "1"),
|
||||
("ChatBubblesParty", "1"),
|
||||
// which held them as `const bool` from 0598 until this window had a page for them. Both are
|
||||
// the binary's own (registrar `0x603280` — wow-re `object-layer/scratch/chat-bubble.md`:
|
||||
// `ChatBubbles` "1", `ChatBubblesParty` "0"); the party half shipped ON from 0598 to 1804 on
|
||||
// the director's `/p` ask, and is a click away on the Chat page.
|
||||
same("ChatBubbles", "1"),
|
||||
same("ChatBubblesParty", "0"),
|
||||
// *Detailed Loot Information* (1589, the Chat page) — 1.12's `showLootSpam`, whose subject is
|
||||
// group LOOT ROLLS (its own tooltip: "Uncheck this to hide individual loot roll messages and
|
||||
// only show the winner"). Registered `"1"`, **byte-read**: wow-re's census of `0xb4e2bc`
|
||||
@@ -275,7 +513,7 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// default string `0x82e748` = "1", **category** 5 — and exactly four references to the global,
|
||||
// one writer and three readers, all in the roll-line composers. The knob is
|
||||
// [`crate::ui_loot::LootConfig::show_loot_spam`], welded to that default below.
|
||||
("showLootSpam", "1"),
|
||||
same("showLootSpam", "1"),
|
||||
// *Guild Member Alert* (1589, the Chat page) — 1.12's `guildMemberNotify`, whose registered
|
||||
// help string says what it does: "Receive notification when guild members log on/off".
|
||||
//
|
||||
@@ -286,21 +524,21 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// finds exactly two readers, both inside `SMSG_GUILD_EVENT`'s handler. The knob is
|
||||
// [`crate::ui_guild::GuildMemberNotify`]; the other three conjuncts of the line's display
|
||||
// condition live on `ui_guild::apply::event`.
|
||||
("guildMemberNotify", "0"),
|
||||
same("guildMemberNotify", "0"),
|
||||
// The minimap's two zoom indices (1131). Byte-verified 1.12 CVars, both registered `"3"`
|
||||
// (wow-re, at the `RegisterCVar 0x63db90` argument slot). No options row drives these — the
|
||||
// +/- buttons on the minimap do, through `Minimap:SetZoom`, exactly as in the reference, where
|
||||
// `set_zoom` writes the live index and `CVar::Set`s the CVar in one breath. The knob is
|
||||
// [`crate::minimap::MinimapZoom`], the widget's live index is seeded from it at UI load.
|
||||
("minimapZoom", "3"),
|
||||
("minimapInsideZoom", "3"),
|
||||
same("minimapZoom", "3"),
|
||||
same("minimapInsideZoom", "3"),
|
||||
// The addon version gate (decision 1292): 1.12's own `checkAddonVersion`, the *Load out of
|
||||
// date AddOns* checkbox INVERTED. Registrar default "1" = check enforced = box unticked —
|
||||
// byte-verified (wow-re `addon-version-gate.md` §1.1: the key appears in Config.wtf exactly
|
||||
// while force-load is on and vanishes when it is turned off, `SaveConfig 0x63d980`'s
|
||||
// skip-default rule). No host knob: its consumers are the load walk (via the persisted value,
|
||||
// [`CvarPersist::addon_version_check`]) and the gate's live per-query read in the VM.
|
||||
("checkAddonVersion", "1"),
|
||||
same("checkAddonVersion", "1"),
|
||||
// Vertical Sync — 1.12's own `gxVSync`, the Video Options checkbox at index 5
|
||||
// (`OptionsFrame.lua`'s `OptionsFrameCheckButtons["VERTICAL_SYNC"]`, in the install's
|
||||
// FrameXML). The knob is [`crate::video::VideoConfig::vsync`], which the window's
|
||||
@@ -313,7 +551,7 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// Two knowing departures from the reference row, both stated on [`crate::video`]: its
|
||||
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
|
||||
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
|
||||
("gxVSync", "1"),
|
||||
same("gxVSync", "1"),
|
||||
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
|
||||
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
|
||||
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
|
||||
@@ -330,7 +568,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
//
|
||||
// Departs from the reference row's `gxRestart = 1` exactly like `gxVSync` above: ours applies
|
||||
// on the click.
|
||||
("gxWindow", "0"),
|
||||
// Byte-read `"0"` at register site `0x63a889` — **for enUS**. Three defaults in this binary
|
||||
// are locale-conditional and this is one: `gxWindow` and `gxMaximize` register `"1"` on zhCN,
|
||||
// `AutoInteract` `"1"` on koKR (wow-re `cvar/scratch/registered-defaults-census.md`, which
|
||||
// caught its own instrument publishing a single arm mid-round). benilla is enUS-only, so `"0"`
|
||||
// is the answer here; the note exists so the next reader does not take a locale-conditional
|
||||
// default for an unconditional one.
|
||||
same("gxWindow", "0"),
|
||||
// The **windowed** size, `gxResolution` — 1.12's own CVar name, narrowed to half its job.
|
||||
// There it is the display mode *and* the backbuffer; here it is only what "windowed" means,
|
||||
// because fullscreen is the monitor's own size and we expose no mode list to pick from (the
|
||||
@@ -339,7 +583,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// A **string** CVar, like it is in the reference — the one row [`apply_to_knobs`] has to match
|
||||
// ahead of its numeric parse. Default is the 1600×900 that was the client's only size before
|
||||
// 1627, so a windowed run is bit-for-bit where it was.
|
||||
("gxResolution", "1600x900"),
|
||||
deviates(
|
||||
"gxResolution",
|
||||
"1600x900",
|
||||
"640x480",
|
||||
"1627: narrowed to the WINDOWED size only — fullscreen is the monitor's own and we expose \
|
||||
no mode list, and 640x480 is not a window anyone would ship a client at",
|
||||
),
|
||||
// The body panes' half-rate render (decision 1444) — **benilla's own CVar**, no 1.12
|
||||
// counterpart: the reference draws its doll inside the main pass (no second view exists to
|
||||
// rate-limit), while our RTT booths (1069) re-run the render graph per pane per frame. "1" =
|
||||
@@ -350,7 +600,12 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// a smoother doll (a look-call); the 08-25 weak-GPU perf reports (B329) measured the cost —
|
||||
// ~1.6 ms at 1600×900, 7.6 ms at 4K, per frame while a body pane is open — and the director
|
||||
// retested the 30 fps doll as fine. Full-rate is one `SetCVar("boothHalfRate", 0)` away.
|
||||
("boothHalfRate", "1"),
|
||||
ours(
|
||||
"boothHalfRate",
|
||||
"1",
|
||||
"1444/1607: benilla's own — the reference draws its doll inside the main pass and has no \
|
||||
second view to rate-limit",
|
||||
),
|
||||
// The select screen's memory of who you last entered the world as (decision 1622) — 1.12's
|
||||
// own `lastCharacterIndex`, help string "Last character selected". **No host knob**: the live
|
||||
// value is the character screen's own state ([`crate::char_select::Roster::pending_index`]),
|
||||
@@ -380,7 +635,7 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// Latched means a change is PENDING until the next launch — the reference's own callback
|
||||
// echoes "set pending gxRestart" — so this row persists and `GetCVar` answers it, while the
|
||||
// camera keeps what it was born with. `$WOW_MSAA` overrides it session-only, below.
|
||||
("gxMultisample", "1"),
|
||||
same("gxMultisample", "1"),
|
||||
// The multisample triple's other two thirds. The reference's Video dropdown formats all three
|
||||
// into one row (`MULTISAMPLING_FORMAT_STRING` = "%d-bit color %d-bit depth %dx multisample")
|
||||
// and `GetCurrentMultisampleFormat 0x48c580` looks up all three by name to find which row is
|
||||
@@ -394,8 +649,19 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// shape to keep. The defaults here are the literals that pair matches on every target we ship;
|
||||
// if a target ever disagrees the dropdown's own row wins, because it is written from the live
|
||||
// enumeration.
|
||||
("gxColorBits", "32"),
|
||||
("gxDepthBits", "32"),
|
||||
deviates(
|
||||
"gxColorBits",
|
||||
"32",
|
||||
"16",
|
||||
"1643: these describe, they do not steer — the pair is our swapchain's own, and every \
|
||||
format `MsaaFormats` publishes carries it",
|
||||
),
|
||||
deviates(
|
||||
"gxDepthBits",
|
||||
"32",
|
||||
"16",
|
||||
"1643: as `gxColorBits` — the depth half of the same descriptive pair",
|
||||
),
|
||||
// **The texture filter policy** — 1.12's own `trilinear` and `anisotropic`, over
|
||||
// `benilla_assets::TexFilterSetting`. The defaults are the reference's registered strings, and
|
||||
// benilla had neither CVar: it hardcoded trilinear + aniso 8 at every sampler it built, which
|
||||
@@ -417,11 +683,18 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
//
|
||||
// This is the same shape as `gxMultisample` above, which also registers the value the hardware
|
||||
// table yields rather than a literal the registrar never emits on a modern machine.
|
||||
("trilinear", "1"),
|
||||
overridden(
|
||||
"trilinear",
|
||||
"1",
|
||||
"0",
|
||||
"1645: `hwDetect` sets it from `VideoHardware.dbc` field 9 before the first frame, and \
|
||||
that field is 1 on both fallback rows an unlisted modern GPU can reach — measured on the \
|
||||
reference's own `Logs/gx.log` (`videoID: 170`)",
|
||||
),
|
||||
// `anisotropic` registers `"1"` — off — and here the registrar's string IS the answer: it is
|
||||
// **not** one of `hwDetect`'s sixteen (scan of `[0x639a60, 0x639b80)`: sixteen record-pointer
|
||||
// reads, `0xc7f2e4` absent), so nothing overwrites it on any path.
|
||||
("anisotropic", "1"),
|
||||
same("anisotropic", "1"),
|
||||
// **Render scale** (decision 1639) — benilla's own CVar, no 1.12 counterpart, in the
|
||||
// `boothHalfRate` / `SoundOutputLimiter` mould: the reference has no such dial because it has
|
||||
// no second buffer to hang one on. The world renders into the composite lane's off-screen image
|
||||
@@ -434,8 +707,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// Default "1" is off, and that is load-bearing rather than cautious: at 1.0 the lane reproduces
|
||||
// its pre-1639 numbers bit-for-bit, so no visual golden in the tree moves. `$WOW_RENDER_SCALE`
|
||||
// overrides it session-only, below.
|
||||
("renderScale", "1"),
|
||||
(crate::char_select::CVAR_LAST_CHARACTER, "0"),
|
||||
ours(
|
||||
"renderScale",
|
||||
"1",
|
||||
"1639: benilla's own — the reference has no off-screen buffer to hang a resolution dial \
|
||||
on; its nearest equivalent, `gxResolution`, drops the interface with the world",
|
||||
),
|
||||
same(crate::char_select::CVAR_LAST_CHARACTER, "0"),
|
||||
];
|
||||
|
||||
/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in
|
||||
@@ -906,7 +1184,7 @@ fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
|
||||
};
|
||||
let known: HashSet<String> = REGISTERED
|
||||
.iter()
|
||||
.map(|(n, _)| n.to_ascii_lowercase())
|
||||
.map(|r| r.name.to_ascii_lowercase())
|
||||
.collect();
|
||||
for (name, value) in &cvars {
|
||||
let key = name.to_ascii_lowercase();
|
||||
@@ -1034,7 +1312,7 @@ fn sync_cvars(
|
||||
.filter(|(k, _)| !persist.env_overridden.contains(&k.to_ascii_lowercase()))
|
||||
.map(|(k, v)| (k.clone(), v.clone())),
|
||||
);
|
||||
script.register_cvars(REGISTERED.iter().copied());
|
||||
script.register_cvars(registered_pairs());
|
||||
// The Video dropdown's menu — what this device actually accepts, enumerated once at
|
||||
// `finish()` by `view::MsaaSupportPlugin` (decision 1631) and handed over whole. Pushed
|
||||
// here rather than owned by the VM because the list is a fact about the render adapter,
|
||||
@@ -1272,6 +1550,108 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::ui_script::DEFAULT_UI_SCALE;
|
||||
|
||||
/// **The standard, enforced: a benilla option's default IS the reference's** (decision 1804)
|
||||
/// — every row's [`Reference`] column stands up.
|
||||
///
|
||||
/// This is the half that could not be a convention. Before it, the reference's value for a row
|
||||
/// lived only in the prose above that row, which meant a default could be *chosen* without
|
||||
/// anyone establishing what the client it imitates does — and five of them were: NPC and own
|
||||
/// overhead names, enemy V-plates, party chat bubbles and the camera's max-distance factor all
|
||||
/// shipped ON or raised while a stock 1.12 client ships them off or low, each one a reasonable
|
||||
/// call on its own day and none of them visible as a *set* until somebody went looking. A
|
||||
/// third column, mandatory and typed, is what makes the question unskippable; this test is
|
||||
/// what makes the answer stay true.
|
||||
///
|
||||
/// It checks **both directions**, which is the part that matters over years:
|
||||
/// - a [`Reference::Same`] row whose default has drifted off the reference's fails — you
|
||||
/// cannot edit a default and leave the claim behind;
|
||||
/// - a [`Reference::Deviates`] or [`Reference::Overridden`] row that has quietly come back
|
||||
/// into agreement *also* fails, because a stale deviation note is worse than none: it hides
|
||||
/// that we are already faithful and invites the next reader to "restore" a divergence.
|
||||
///
|
||||
/// Values are parse-compared where both sides are numeric, so `"1"` and `"1.0"` are one claim.
|
||||
#[test]
|
||||
fn defaults_stand_where_the_reference_column_says() {
|
||||
/// One value against another — numeric when both parse, textual otherwise (`gxResolution`,
|
||||
/// `realmList`).
|
||||
fn agrees(ours: &str, theirs: &str) -> bool {
|
||||
match (ours.parse::<f32>(), theirs.parse::<f32>()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => ours == theirs,
|
||||
}
|
||||
}
|
||||
for row in REGISTERED {
|
||||
let name = row.name;
|
||||
match &row.reference {
|
||||
Reference::Same(value) => assert!(
|
||||
agrees(row.default, value),
|
||||
"{name}: the row claims the reference registers {value:?} and we ship the \
|
||||
same, but our default is {:?}. If the reference really does differ, this is \
|
||||
a `deviates` row and owes a reason.",
|
||||
row.default,
|
||||
),
|
||||
Reference::Deviates { value, why } => {
|
||||
assert!(
|
||||
!agrees(row.default, value),
|
||||
"{name}: a `deviates` row that no longer deviates — our {:?} IS the \
|
||||
reference's. Demote it to `same`; a stale deviation hides that we are \
|
||||
faithful again.",
|
||||
row.default,
|
||||
);
|
||||
assert!(!why.trim().is_empty(), "{name}: a deviation owes a reason");
|
||||
}
|
||||
Reference::Overridden { registered, why } => {
|
||||
assert!(
|
||||
!agrees(row.default, registered),
|
||||
"{name}: an `overridden` row whose default is just the registered string \
|
||||
{registered:?} — that is `same`, and saying otherwise buries a real \
|
||||
override behind a false one.",
|
||||
);
|
||||
assert!(
|
||||
!why.trim().is_empty(),
|
||||
"{name}: an override owes its mechanism"
|
||||
);
|
||||
}
|
||||
Reference::Ours(why) => assert!(
|
||||
!why.trim().is_empty(),
|
||||
"{name}: a CVar the reference does not have owes the reason it exists",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The inventory** — the exact set of options benilla ships at something other than what a
|
||||
/// stock 1.12 client ships, as one readable list.
|
||||
///
|
||||
/// The list is the deliverable, not the assertion: a reviewer (or the director) reads *this*
|
||||
/// to answer "where do we differ, and is each one still worth it?", and growing it is a
|
||||
/// deliberate edit rather than a side effect of adding a row. [`Reference::Overridden`] and
|
||||
/// [`Reference::Ours`] are deliberately **not** here — those rows follow the reference's
|
||||
/// behaviour, or have no reference behaviour to follow.
|
||||
///
|
||||
/// Every name below is argued at its own row; this is the index, and the reason it is sorted
|
||||
/// is that the table's order is a load order, not a ranking.
|
||||
#[test]
|
||||
fn the_options_that_leave_the_reference_are_this_list_and_no_other() {
|
||||
let mut names: Vec<&str> = REGISTERED
|
||||
.iter()
|
||||
.filter(|r| matches!(r.reference, Reference::Deviates { .. }))
|
||||
.map(|r| r.name)
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"SoundReverb",
|
||||
"autoSelfCast",
|
||||
"gxColorBits",
|
||||
"gxDepthBits",
|
||||
"gxResolution",
|
||||
"realmList",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Every registered default IS the code constant it mirrors — parse-compared so "1" vs
|
||||
/// "1.0" cannot fail it, welded so neither side can drift alone.
|
||||
///
|
||||
@@ -1285,7 +1665,7 @@ mod tests {
|
||||
fn registered_defaults_mirror_the_code_truths() {
|
||||
let d: BTreeMap<&str, f32> = REGISTERED
|
||||
.iter()
|
||||
.filter_map(|(n, v)| v.parse::<f32>().ok().map(|f| (*n, f)))
|
||||
.filter_map(|r| r.default.parse::<f32>().ok().map(|f| (r.name, f)))
|
||||
.collect();
|
||||
let sound = SoundConfig::default();
|
||||
assert_eq!(d["MasterVolume"], sound.master);
|
||||
@@ -1352,27 +1732,37 @@ mod tests {
|
||||
crate::ui_trade::BlockTrades::default().0
|
||||
);
|
||||
assert_eq!(d["BlockTrades"], 0.0, "an unset BlockTrades allows trades");
|
||||
// The name trio (0992) welds to NameConfig's defaults the same way.
|
||||
// The name trio (0992) welds to NameConfig's defaults the same way — and all three are
|
||||
// the binary's own registrar values now (1804), not two director pins over one.
|
||||
let names = NameConfig::default();
|
||||
assert_eq!(d["UnitNamePlayer"] != 0.0, names.player);
|
||||
assert_eq!(d["UnitNameNPC"] != 0.0, names.npc);
|
||||
assert_eq!(d["UnitNameOwn"] != 0.0, names.own);
|
||||
// The V-plate pair welds to VPlateMode's defaults — the enemy "1" is the 0167 director
|
||||
// divergence from the binary's both-OFF boot, the friendly "0" is faithful (0599).
|
||||
assert!(
|
||||
names.player && !names.npc && !names.own,
|
||||
"the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\""
|
||||
);
|
||||
// The V-plate pair welds to VPlateMode's defaults — both OFF, which is the reference's
|
||||
// own boot state on both of its halves (the `[0xc4da34]` bitmask and FrameXML's
|
||||
// `NAMEPLATES_ON = nil`). Enemy plates were the 0167 director pin until 1804.
|
||||
let plates = VPlateMode::default();
|
||||
assert_eq!(d[crate::vplates::CVAR_ENEMIES] != 0.0, plates.enemies);
|
||||
assert_eq!(d[crate::vplates::CVAR_FRIENDS] != 0.0, plates.friends);
|
||||
assert!(plates.enemies && !plates.friends, "the shipped boot pair");
|
||||
assert!(
|
||||
!plates.enemies && !plates.friends,
|
||||
"a fresh 1.12 client draws no plates until V is pressed"
|
||||
);
|
||||
// ClutterConfig::default() reads $WOW_CLUTTER_DENSITY; the registered default mirrors
|
||||
// the env-less ×2 literal (clutter.rs: "Default ×2 = Medium", 1649) on the panel's 0..2
|
||||
// scale. The weld is the point: the CVar's default and the engine's must be the same
|
||||
// ground cover, or a fresh config writes a row the world does not agree with.
|
||||
assert_eq!(d["WorldDetail"], 1.0);
|
||||
// The bubble pair (1139) welds to BubbleConfig's defaults — including the "1" that
|
||||
// deliberately disagrees with the binary's registered `ChatBubblesParty` "0" (0598).
|
||||
// The bubble pair (1139) welds to BubbleConfig's defaults — both the binary's own since
|
||||
// 1804 (`ChatBubbles` "1", `ChatBubblesParty` "0"; the party half was 0598's director pin).
|
||||
let bubbles = BubbleConfig::default();
|
||||
assert_eq!(d["ChatBubbles"] != 0.0, bubbles.all);
|
||||
assert_eq!(d["ChatBubblesParty"] != 0.0, bubbles.party);
|
||||
assert!(bubbles.all && !bubbles.party, "the binary's own pair");
|
||||
// The minimap pair (1131) welds to the widget's own `MINIMAP_DEFAULT_ZOOM`, which is the
|
||||
// byte-verified registration default `"3"` — one truth, mirrored in three places.
|
||||
let zoom = MinimapZoom::default();
|
||||
@@ -1936,16 +2326,16 @@ mod tests {
|
||||
fn the_string_valued_cvars_are_the_realm_and_the_windowed_size() {
|
||||
let mut strings: Vec<&str> = REGISTERED
|
||||
.iter()
|
||||
.filter(|(_, v)| v.parse::<f32>().is_err())
|
||||
.map(|(n, _)| *n)
|
||||
.filter(|r| r.default.parse::<f32>().is_err())
|
||||
.map(|r| r.name)
|
||||
.collect();
|
||||
strings.sort_unstable(); // the list is the claim, not where the rows sit in the table
|
||||
assert_eq!(strings, vec!["gxResolution", "realmList", "realmName"]);
|
||||
let default_of = |name: &str| {
|
||||
REGISTERED
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, v)| *v)
|
||||
.find(|r| r.name == name)
|
||||
.map(|r| r.default)
|
||||
.expect("registered")
|
||||
};
|
||||
assert_eq!(default_of("realmName"), "");
|
||||
|
||||
@@ -37,16 +37,15 @@
|
||||
//! the face's own ascent fraction ([`UiFontAtlas::ascent_ratio`], the `[0x17c]` load_param); the
|
||||
//! sub-pitch ascent/descent extents inside the top/bottom lines remain the gx-boundary
|
||||
//! INFERRED residual (a director A/B pins the exact pixels).
|
||||
//! - **Show gate** (`ShouldShowName 0x6070a0`): own unit → `UnitNameOwn` (binary default OFF,
|
||||
//! checked before the rescue; benilla defaults **ON** — the director's directive — plus a
|
||||
//! benilla-side fade gate: the own name hides with the fully-faded first-person avatar, our
|
||||
//! reading of the gate's INFERRED `vtable+0x58` can-show leg); the **current TARGET shows
|
||||
//! - **Show gate** (`ShouldShowName 0x6070a0`): own unit → `UnitNameOwn` (binary default OFF —
|
||||
//! ours too since 1804 — checked before the rescue; plus a benilla-side fade gate: the own name
|
||||
//! hides with the fully-faded first-person avatar, our reading of the gate's INFERRED
|
||||
//! `vtable+0x58` can-show leg); the **current TARGET shows
|
||||
//! regardless of cvars** — the rescue global `[0xb4e2d8]` is the *selection*, pinned by the
|
||||
//! combat-flash RE; the earlier "mouseover" reading of the same global is CORRECTED here; a
|
||||
//! **dead creature shows only via the target rescue** (director-verified on the reference; the
|
||||
//! byte leg is a flagged pin); players → `UnitNamePlayer` (ON); NPCs → `UnitNameNPC` (binary
|
||||
//! default OFF; benilla defaults **ON** — the director's directive; the reference install's
|
||||
//! effective default is a flagged open).
|
||||
//! byte leg is a flagged pin); players → `UnitNamePlayer` (default ON); NPCs → `UnitNameNPC`
|
||||
//! and own → `UnitNameOwn` (both default OFF, the binary's own — see [`NameConfig`]).
|
||||
//! - **Lines** (`0x608f50`): NPC = name + `<Subname>` (an empty wire subname is no line); player =
|
||||
//! [flag prefixes +] name (guild/PVP-title lines wait on data that doesn't stream yet). The
|
||||
//! prefixes are the a1–a3 vtable-slot decorations of the line stack (`+0x7c/+0x80/+0x84`),
|
||||
@@ -77,9 +76,15 @@ const SCALE_RATE: f32 = 1.5; // [0x8112ac]
|
||||
/// benilla's nameplate config (the client's `0xce8720` cvar mask, reduced to what streams today),
|
||||
/// player-settable since 0992: 1.12's own `UnitNamePlayer`/`UnitNameNPC`/`UnitNameOwn` CVars over
|
||||
/// these gates (the Options window's Nameplates page, through the 0954 store — the arms live in
|
||||
/// [`crate::cvars`]). Defaults: `npc` and `own` are ON by the director's directives ("figure out
|
||||
/// text name over NPCs"; "we should show our name", 2026-07-12) — the byte-verified binary
|
||||
/// defaults are both "0"; `player` ON is the byte-verified default.
|
||||
/// [`crate::cvars`]).
|
||||
///
|
||||
/// **The defaults are the reference's, byte-verified** (wow-re
|
||||
/// `object-layer/scratch/overhead-name.md`, the `0x6c7470` registrar: `UnitNamePlayer` `"1"`,
|
||||
/// `UnitNameNPC` `"0"`, `UnitNameOwn` `"0"`). `npc` and `own` shipped ON from 2026-07-12 to
|
||||
/// 1804 — a pair of director directives ("figure out text name over NPCs"; "we should show our
|
||||
/// name") that were about making the feature *visible while it was being built*, and stayed as
|
||||
/// the shipped default long after it worked. Both rows are on the Nameplates page; turning them
|
||||
/// back on is a click.
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
pub(crate) struct NameConfig {
|
||||
pub(crate) player: bool,
|
||||
@@ -91,8 +96,8 @@ impl Default for NameConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
player: true,
|
||||
npc: true,
|
||||
own: true,
|
||||
npc: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,29 +98,38 @@ impl PressGesture {
|
||||
/// the camera CVars, wow-re `follow-camera`): max orbit = `cameraDistanceMax × cameraDistanceMaxFactor`,
|
||||
/// **hard-capped at 50**; the low clamp is **0** — zoom-to-first-person (at distance 0 the eye sits at
|
||||
/// the framing pivot, inside the head, and the avatar fades to invisible — see
|
||||
/// [`benilla_world::model_fade::self_model_fade_alpha`]). The out-of-box *default* max is 15 (`15 × 1`); we use
|
||||
/// **30** as the max zoom-out — the "Max Camera Distance" setting fully raised (`cameraDistanceMax 15 ×
|
||||
/// cameraDistanceMaxFactor 2.0`, well under the 50 cap), matched against the reference client. (The 2.0
|
||||
/// factor cap is inferred from the ref-client comparison, not byte-verified — the RE pinned the CVar
|
||||
/// *defaults* and the 50 hard cap, not the factor slider's own max.) Our starting zoom is 15 — pulled
|
||||
/// back a bit further than vanilla's own default for a wider view.
|
||||
/// [`benilla_world::model_fade::self_model_fade_alpha`]). The out-of-box max is **15** (`15 × 1`) —
|
||||
/// the reference's, and since 1804 ours. This file shipped the factor fully raised (30 yd) from
|
||||
/// the day it was written — a taste call ("a wider view") that nobody had weighed against the
|
||||
/// client it imitates. The slider is still there and still reaches 30; it just is not where a
|
||||
/// fresh install starts. Our starting zoom
|
||||
/// is 15 — the reference's own shipped `cameraDistance` is 5.55 (wow-re
|
||||
/// `camera-settings-persistence.md` §2), a divergence this file has always carried in its own words
|
||||
/// ("pulled back a bit further than vanilla's own default for a wider view") and one 1804 leaves
|
||||
/// alone: it is the camera's *initial state*, not a settings row, and it is the director's look.
|
||||
pub(super) const CAM_DIST_MIN: f32 = 0.0;
|
||||
/// The reference's `cameraDistanceMax` — the BASE the factor multiplies (registrar default 15).
|
||||
/// Not exposed: 1.12's panel offers only the factor, so this stays the constant it is there.
|
||||
pub(super) const CAM_DIST_BASE_MAX: f32 = 15.0;
|
||||
/// `cameraDistanceMaxFactor`'s slider range — 1.12's own (MAX_FOLLOW_DIST: 1 … 2, step 0.1).
|
||||
pub(crate) const CAM_DIST_FACTOR_RANGE: std::ops::RangeInclusive<f32> = 1.0..=2.0;
|
||||
/// The shipped max orbit: the factor slider fully raised, which is where benilla has always sat.
|
||||
/// The orbit ceiling at the factor slider's **top** — the furthest any saved view or restored
|
||||
/// camera pose may sit. Not the shipped default any more (1804): [`ZoomLimit::default`] is the
|
||||
/// slider at rest, `1 × 15`. This is what a distance read back off disk is clamped to, which has
|
||||
/// to admit the whole slider range rather than only its resting point.
|
||||
pub(super) const CAM_DIST_MAX: f32 = CAM_DIST_BASE_MAX * 2.0;
|
||||
pub(super) const CAM_DIST_DEFAULT: f32 = 15.0;
|
||||
|
||||
/// The max-orbit knob (decision 1140) — 1.12's `cameraDistanceMaxFactor` over the base above.
|
||||
/// A fourth frozen constant made reachable: [`CAM_DIST_MAX`] was the only zoom ceiling there was.
|
||||
///
|
||||
/// **Our registered default is the factor at 2.0, the reference's registrar ships 1.0** — a named
|
||||
/// divergence, and the one this file has always carried in its own words ("pulled back a bit
|
||||
/// further than vanilla's own default for a wider view"). Lowering the slider re-clamps the live
|
||||
/// target on the next frame, so the view comes in rather than waiting for the next wheel notch.
|
||||
/// **The default is the reference's 1.0** — `15 yd`, byte-pinned (wow-re
|
||||
/// `ui/scratch/follow-camera.md`: "cameraDistanceMax 15.0, cameraDistanceMaxFactor 1.0"). It was
|
||||
/// 2.0 from 1140 until 1804, which is the whole reason that record exists: the raised factor was a
|
||||
/// reasonable taste call on its day and it was never weighed as a *default*, so benilla shipped a
|
||||
/// camera that started 15 yd further out than the client it imitates. Raising the slider re-clamps
|
||||
/// nothing; lowering it re-clamps the live target on the next frame, so the view comes in rather
|
||||
/// than waiting for the next wheel notch.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct ZoomLimit {
|
||||
/// Max orbit distance in yards — `CAM_DIST_BASE_MAX × factor`, hard-capped like the client's 50.
|
||||
@@ -129,7 +138,11 @@ pub(crate) struct ZoomLimit {
|
||||
|
||||
impl Default for ZoomLimit {
|
||||
fn default() -> Self {
|
||||
Self { max: CAM_DIST_MAX }
|
||||
// The slider at rest — `cameraDistanceMaxFactor` 1.0 × the 15 yd base, i.e. the reference's
|
||||
// own out-of-box ceiling. `CAM_DIST_MAX` is the slider's TOP, and belongs to the clamps.
|
||||
Self {
|
||||
max: CAM_DIST_BASE_MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -637,8 +637,8 @@ mod tests {
|
||||
let registered = |name: &str| {
|
||||
crate::cvars::REGISTERED
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||
.map(|(_, d)| *d)
|
||||
.find(|r| r.name.eq_ignore_ascii_case(name))
|
||||
.map(|r| r.default)
|
||||
};
|
||||
for view in 0..VIEW_COUNT {
|
||||
for field in 0..3 {
|
||||
|
||||
@@ -36,17 +36,27 @@ use crate::ui_party::{GroupState, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP};
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC),
|
||||
/// one per denomination; [`coin_icon`] picks by the highest nonzero denomination. All three VERIFIED
|
||||
/// to extract from `interface.MPQ` (`INV_Misc_Coin_01`=gold pile, `_03`=silver, `_05`=copper).
|
||||
/// **six of them, one per decade of copper**, all VERIFIED to extract from `interface.MPQ`.
|
||||
///
|
||||
/// **Stated approximation (documented gap).** The real 1.12 client chooses the coin-pile art from the
|
||||
/// exact amount inside `GetLootSlotInfo` — a client-internal C selection that is **not** RE-recorded
|
||||
/// (no wow-re node owns it; not dispatched). Mapping the icon to the highest nonzero denomination is
|
||||
/// our faithful-shaped stand-in: a gold amount shows the gold pile, a silver-only amount the silver
|
||||
/// pile, a copper-only amount the copper pile.
|
||||
const COIN_ICON_GOLD: &str = "Interface\\Icons\\INV_Misc_Coin_01";
|
||||
const COIN_ICON_SILVER: &str = "Interface\\Icons\\INV_Misc_Coin_03";
|
||||
const COIN_ICON_COPPER: &str = "Interface\\Icons\\INV_Misc_Coin_05";
|
||||
/// This used to be three, chosen by the highest nonzero denomination, and said so in a "stated
|
||||
/// approximation" note whose stated reason was that the real selection was not RE-recorded. It is:
|
||||
/// `0x4c2460` sends the money row to `0x4c248a call 0x6c62d0`, whose ladder at
|
||||
/// `0x6c6307`–`0x6c6386` is exactly the six thresholds below (wow-re
|
||||
/// `system/ui/scratch/loot-slot-record.md` §4). The gap closed when that note landed and nothing
|
||||
/// here noticed — which is why a "documented approximation" is a thing to re-check against the
|
||||
/// sibling repo, not a thing to leave standing.
|
||||
///
|
||||
/// The icon *order* is the client's own and it is not the numeric one — `_05, _06, _03, _04, _01,
|
||||
/// _02` as the amount climbs. What each of the six pieces of art depicts is not claimed here; the
|
||||
/// ladder is byte-derived and the art is whatever the reference picks at that step.
|
||||
const COIN_ICONS: [(u32, &str); 6] = [
|
||||
(10, "Interface\\Icons\\INV_Misc_Coin_05"),
|
||||
(100, "Interface\\Icons\\INV_Misc_Coin_06"),
|
||||
(1_000, "Interface\\Icons\\INV_Misc_Coin_03"),
|
||||
(10_000, "Interface\\Icons\\INV_Misc_Coin_04"),
|
||||
(100_000, "Interface\\Icons\\INV_Misc_Coin_01"),
|
||||
(u32::MAX, "Interface\\Icons\\INV_Misc_Coin_02"),
|
||||
];
|
||||
/// `item_template.bonding == BIND_WHEN_PICKED_UP` — the first of the two conjuncts that defer a
|
||||
/// loot take behind the LOOT_BIND confirm (VERIFIED vmangos `ItemPrototype.h`'s `ItemBondingType`:
|
||||
/// `NO_BIND` 0, `BIND_WHEN_PICKED_UP` 1, `BIND_WHEN_EQUIPPED` 2, `BIND_WHEN_USE` 3, `QUEST_ITEM` 4;
|
||||
@@ -58,8 +68,19 @@ const BIND_WHEN_PICKED_UP: u32 = 1;
|
||||
/// soulbinding something they meant to pass to a groupmate, and nobody passes a white.
|
||||
const BIND_CONFIRM_MIN_QUALITY: u32 = 2;
|
||||
|
||||
/// The fixed quality of the synthesized coin row (common/white — the money text reads as plain).
|
||||
const COIN_QUALITY: u32 = 1;
|
||||
/// The quality `GetLootSlotInfo` answers for the synthesized coin row: **0, Poor** — so the money
|
||||
/// text renders GREY, not white.
|
||||
///
|
||||
/// Byte-derived, and it corrects a stated approximation ("common/white — the money text reads as
|
||||
/// plain"). `0x4c23a0`'s second guard IS its coin leg: `0x4c23da test ecx,ecx` / `0x4c23dc jne`, so
|
||||
/// the money row (0-based slot 0 with `[0xb71ba0] != 0`) falls through to
|
||||
/// `0x4c23de xor eax,eax; ret` and never reaches the item-cache block the item rows read
|
||||
/// `[rec+0x1c]` from. The same guard is why its `quantity` is 0 (`0x4c22fd`). wow-re
|
||||
/// `system/ui/scratch/loot-slot-record.md` §10, a §5 round of five workers.
|
||||
///
|
||||
/// Nothing downstream re-colours it: stock `LootFrame_Update` has no coin special case
|
||||
/// (`LootFrame.lua:81-85`) and `ITEM_QUALITY_COLORS[0]` is `0xff9d9d9d`.
|
||||
const COIN_QUALITY: u32 = 0;
|
||||
/// The 1.12 coin-denomination words, QUOTED from `Interface\FrameXML\GlobalStrings.lua` (verified
|
||||
/// extract): `GOLD = "Gold"` (l.2025), `SILVER = "Silver"` (l.3465), `COPPER = "Copper"` (l.865).
|
||||
const GOLD_WORD: &str = "Gold";
|
||||
@@ -656,16 +677,14 @@ fn format_money(copper: u32) -> String {
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// The coin-pile icon for a copper amount: the highest nonzero denomination's pile (gold ▸ silver ▸
|
||||
/// copper). See [`COIN_ICON_GOLD`] for the stated-approximation note.
|
||||
/// The coin-pile icon for a copper amount — the reference's six-step ladder, see [`COIN_ICONS`].
|
||||
/// `u32::MAX` is the last step's bound, so the `map_or` fallback is unreachable and is there only
|
||||
/// because the table is data rather than a match.
|
||||
fn coin_icon(copper: u32) -> &'static str {
|
||||
if copper >= 10000 {
|
||||
COIN_ICON_GOLD
|
||||
} else if copper >= 100 {
|
||||
COIN_ICON_SILVER
|
||||
} else {
|
||||
COIN_ICON_COPPER
|
||||
}
|
||||
COIN_ICONS
|
||||
.iter()
|
||||
.find(|(below, _)| copper < *below)
|
||||
.map_or(COIN_ICONS[5].1, |(_, icon)| icon)
|
||||
}
|
||||
|
||||
/// Resolve one wire [`LootItem`] into the Lua-facing [`LootRow`]: the icon comes straight from the
|
||||
@@ -724,6 +743,25 @@ fn resolve_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any row of this open loot is still waiting on its item-template answer — the reference's
|
||||
/// outstanding-query counter `[0xb71b44]`, in predicate form (decision 1805).
|
||||
///
|
||||
/// A row counts as waiting while its name is absent AND the server has not yet answered at all. A
|
||||
/// **negative** answer ("no such entry") releases it: the reference's counter is decremented by the
|
||||
/// arrival callback either way, and a window held open forever by an entry nobody will ever describe
|
||||
/// is strictly worse than one that opens showing the cache-miss sentinels — which is exactly what
|
||||
/// the reference paints in that case.
|
||||
///
|
||||
/// The coin row never waits; it has no template.
|
||||
fn templates_outstanding(items: &Items, snap: &LootSnapshot) -> bool {
|
||||
snap.rows.iter().flatten().any(|r| {
|
||||
!r.is_coin
|
||||
&& r.item_id != 0
|
||||
&& r.name.is_none()
|
||||
&& !items.template_answered_unknown(r.item_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the Lua-facing snapshot from [`LootState`] — `None` when no loot is open. One entry per
|
||||
/// slot of the **fixed** open-time layout: the coin pile first when the loot opened with gold, then
|
||||
/// the items in wire order. A slot already looted stays in the list as `None` — the gap the
|
||||
@@ -743,7 +781,11 @@ fn snapshot(
|
||||
rows.push(loot.has_coin().then(|| LootRow {
|
||||
name: Some(format_money(loot.gold)),
|
||||
texture: Some(coin_icon(loot.gold).into()),
|
||||
quantity: 1,
|
||||
// 0, not 1, and for the same reason as [`COIN_QUALITY`]: `0x4c22e0`'s coin guard
|
||||
// returns before the record read (`0x4c22fd`). Invisible either way — stock
|
||||
// `LootFrame_Update` hides the count string unless `quantity > 1` — but it is the
|
||||
// value the accessor answers.
|
||||
quantity: 0,
|
||||
quality: Some(COIN_QUALITY),
|
||||
is_coin: true,
|
||||
item_id: 0,
|
||||
@@ -976,6 +1018,22 @@ fn feed_loot(
|
||||
script.set_loot(fresh.clone());
|
||||
match (&*last, &fresh) {
|
||||
(None, Some(snap)) => {
|
||||
// **The window does not open until every row's item template has landed** (1805). The
|
||||
// reference's copier ends `0x4c1e9f mov eax,[0xb71b44]; test eax,eax; jne 0x4c1eeb` —
|
||||
// if any template query is outstanding it fires NOTHING — and the item-cache arrival
|
||||
// callback `0x4c2ac0` fires `LOOT_OPENED` (`0x10b`) itself on that counter's falling
|
||||
// edge to zero (`0x4c2af6 dec` / `jne`). There is no repaint path to fall back on:
|
||||
// `LootFrame_Update` is reachable only through the XML `<OnShow>`, and `ShowUIPanel`
|
||||
// early-returns on an already-visible frame, so a second `LOOT_OPENED` would do
|
||||
// nothing. Deferring IS the mechanism (wow-re `loot-slot-record.md` §11).
|
||||
//
|
||||
// Returning without advancing `last` re-evaluates next frame; the queries are already
|
||||
// in flight from `snapshot` above, and the auto-loot sweep below waits with the window,
|
||||
// which is the reference's shape too (the same callback runs the sweep instead of
|
||||
// firing when the auto-loot latch is set).
|
||||
if templates_outstanding(&items, snap) {
|
||||
return;
|
||||
}
|
||||
script.fire_event("LOOT_OPENED", vec![]);
|
||||
// era's engine-side auto-loot ([`LootConfig`]): the knob decides, a held SHIFT
|
||||
// inverts it, and the client "clicks" every row itself at the open edge — the
|
||||
@@ -1415,6 +1473,99 @@ mod tests {
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// **The window waits for the item templates** (1805) — the reference's own open rule, and the
|
||||
/// reason the cache-miss sentinels beside it are a floor rather than a plan.
|
||||
///
|
||||
/// `0x4c1cb0` returns without firing while `[0xb71b44]` is non-zero, and the item-cache arrival
|
||||
/// callback `0x4c2ac0` fires `LOOT_OPENED` on that counter's falling edge instead. There is no
|
||||
/// repaint to fall back on: `LootFrame_Update` runs only from `<OnShow>`, and `ShowUIPanel`
|
||||
/// early-returns on an already-visible frame.
|
||||
///
|
||||
/// So: a corpse whose one row is an entry we have never seen must open NOTHING on the first
|
||||
/// pass — and must ask the server for the template — then open exactly once when the answer
|
||||
/// lands. A **negative** answer opens it too; a window held shut forever by an entry nobody can
|
||||
/// describe is our one deliberate divergence from the reference's success-gated callback.
|
||||
#[test]
|
||||
fn the_window_waits_for_every_item_template() {
|
||||
for answer in [
|
||||
Some(ItemInfo {
|
||||
quality: 1,
|
||||
..crate::items::test_template("Thin Cloth Gloves")
|
||||
}),
|
||||
// …and the negative answer, which releases the window rather than holding it shut.
|
||||
None,
|
||||
] {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_message::<crate::sound::LootPickupSound>()
|
||||
.init_resource::<LootState>()
|
||||
.init_resource::<LootErrors>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<GroupState>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<Items>()
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.init_resource::<LootConfig>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
// Registered in a schedule rather than driven by `run_system_once`, because the
|
||||
// whole question is what happens ACROSS frames and `feed_loot`'s memo of the last
|
||||
// snapshot is a `Local`: `run_system_once` builds a new system each call and hands
|
||||
// it a fresh memo, which reads every pass as a first open.
|
||||
.add_systems(bevy::prelude::Update, feed_loot);
|
||||
app.world_mut().resource_mut::<LootState>().open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
0,
|
||||
vec![item(3, TOUGH_JERKY, 1)],
|
||||
);
|
||||
|
||||
let script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
"OPENS = 0\n\
|
||||
local f = CreateFrame(\"Frame\")\n\
|
||||
f:RegisterEvent(\"LOOT_OPENED\")\n\
|
||||
f:SetScript(\"OnEvent\", function() OPENS = OPENS + 1 end)",
|
||||
)
|
||||
.unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
|
||||
let opens = |app: &mut App| {
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval::<i64>("return OPENS")
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Pass one: the template is unknown, so the window stays shut — and the ask goes out.
|
||||
app.update();
|
||||
assert_eq!(
|
||||
opens(&mut app),
|
||||
0,
|
||||
"no LOOT_OPENED while a template is pending"
|
||||
);
|
||||
assert!(
|
||||
sent(&rx).iter().any(
|
||||
|c| matches!(c, ClientCommand::ItemQuery { entry, .. } if *entry == TOUGH_JERKY)
|
||||
),
|
||||
"and the template was asked for"
|
||||
);
|
||||
|
||||
// A second pass with nothing new changes nothing — the deferral is not a one-shot.
|
||||
app.update();
|
||||
assert_eq!(opens(&mut app), 0);
|
||||
|
||||
// The answer lands: the window opens, once.
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(TOUGH_JERKY, answer.clone());
|
||||
app.update();
|
||||
assert_eq!(opens(&mut app), 1, "opened when the last template answered");
|
||||
app.update();
|
||||
assert_eq!(opens(&mut app), 1, "and only once");
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows `LOOT_BIND_CONFIRM` has named so far, in order.
|
||||
fn confirms(app: &mut App) -> Vec<i64> {
|
||||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
@@ -2183,17 +2334,26 @@ mod tests {
|
||||
assert_eq!(format_money(10_000), "1 Gold");
|
||||
}
|
||||
|
||||
/// The reference's six-step ladder at `0x6c6307`–`0x6c6386`, both sides of every boundary.
|
||||
#[test]
|
||||
fn coin_icon_picks_the_highest_nonzero_denomination() {
|
||||
assert_eq!(coin_icon(4), COIN_ICON_COPPER); // copper only
|
||||
assert_eq!(coin_icon(99), COIN_ICON_COPPER);
|
||||
assert_eq!(coin_icon(500), COIN_ICON_SILVER); // 5 silver
|
||||
assert_eq!(coin_icon(10_025), COIN_ICON_GOLD); // gold present ⇒ gold pile
|
||||
assert_eq!(coin_icon(20_000), COIN_ICON_GOLD);
|
||||
fn coin_icon_walks_the_references_six_step_ladder() {
|
||||
let icon = |n: u32| coin_icon(n).rsplit('\\').next().unwrap().to_string();
|
||||
assert_eq!(icon(0), "INV_Misc_Coin_05");
|
||||
assert_eq!(icon(9), "INV_Misc_Coin_05");
|
||||
assert_eq!(icon(10), "INV_Misc_Coin_06");
|
||||
assert_eq!(icon(99), "INV_Misc_Coin_06");
|
||||
assert_eq!(icon(100), "INV_Misc_Coin_03");
|
||||
assert_eq!(icon(999), "INV_Misc_Coin_03");
|
||||
assert_eq!(icon(1_000), "INV_Misc_Coin_04");
|
||||
assert_eq!(icon(9_999), "INV_Misc_Coin_04");
|
||||
assert_eq!(icon(10_000), "INV_Misc_Coin_01");
|
||||
assert_eq!(icon(99_999), "INV_Misc_Coin_01");
|
||||
assert_eq!(icon(100_000), "INV_Misc_Coin_02");
|
||||
assert_eq!(icon(u32::MAX), "INV_Misc_Coin_02");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coin_row_uses_real_words_and_copper_pile_icon() {
|
||||
fn coin_row_uses_real_words_and_the_ladders_icon() {
|
||||
let mut items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
@@ -2217,7 +2377,7 @@ mod tests {
|
||||
let coin = snap.rows[0].as_ref().expect("coin row present");
|
||||
assert!(coin.is_coin);
|
||||
assert_eq!(coin.name.as_deref(), Some("4 Copper"));
|
||||
assert_eq!(coin.texture.as_deref(), Some(COIN_ICON_COPPER));
|
||||
assert_eq!(coin.texture.as_deref(), Some(coin_icon(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2257,7 +2417,7 @@ mod tests {
|
||||
let coin = snap.rows[0].as_ref().expect("coin row present");
|
||||
assert!(coin.is_coin);
|
||||
assert_eq!(coin.name.as_deref(), Some("1 Gold 23 Silver 45 Copper"));
|
||||
assert_eq!(coin.texture.as_deref(), Some(COIN_ICON_GOLD));
|
||||
assert_eq!(coin.texture.as_deref(), Some(coin_icon(12_345)));
|
||||
// The item's name is nil while its template is in flight; its quantity is present.
|
||||
let row = snap.rows[1].as_ref().expect("item row present");
|
||||
assert!(!row.is_coin);
|
||||
|
||||
@@ -37,9 +37,11 @@ fn harness() -> UiScript {
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
|
||||
// The timer switch, seeded BEFORE the bar loads. 1.12 declares it in UIOptionsFrame.lua
|
||||
// (default "0"); we have no counterpart to that file, so it lives with the row that drives it
|
||||
// (OptionsFrame.xml) and ships as "1" — the director's call, 0255/1139. Order matters: the
|
||||
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
|
||||
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
|
||||
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
|
||||
// it back on the reference's "0". These tests are about the timer text and the geometry it
|
||||
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
|
||||
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
|
||||
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
|
||||
// out for the wrong one.
|
||||
@@ -436,10 +438,11 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
|
||||
|
||||
/// **`SHOW_BUFF_DURATIONS`** (decision 1139) — 0255 shipped the durations-shown geometry with no
|
||||
/// switch because there was no panel to hang one on, and said the other branch was already there
|
||||
/// waiting. This is it: the global (ours "1", the reference's "0") hides the timer text and closes
|
||||
/// the 15px gutter each row leaves for it, down to the 5px the columns use. What it does NOT touch
|
||||
/// is the warning flash — with the numbers gone, the pulse is the only thing left saying an aura is
|
||||
/// about to drop, and the reference pulses from `BuffButton_OnUpdate` regardless of the setting.
|
||||
/// waiting. This is it: the global — the reference's "0" since 1804, planted "1" by this file's
|
||||
/// harness — hides the timer text and closes the 15px gutter each row leaves for it, down to the
|
||||
/// 5px the columns use. What it does NOT touch is the warning flash — with the numbers gone, the
|
||||
/// pulse is the only thing left saying an aura is about to drop, and the reference pulses from
|
||||
/// `BuffButton_OnUpdate` regardless of the setting.
|
||||
#[test]
|
||||
fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -458,7 +461,10 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
|
||||
)],
|
||||
);
|
||||
frame(&mut s, 0.1);
|
||||
assert!(shown(&s, "BuffButton0Duration"), "timers on by default");
|
||||
assert!(
|
||||
shown(&s, "BuffButton0Duration"),
|
||||
"timers on — the harness planted the switch"
|
||||
);
|
||||
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
|
||||
s.resolve();
|
||||
let shown_gap = s
|
||||
|
||||
@@ -6,12 +6,23 @@ use benilla_ui::script::{ScriptValue, UiScript, UnitState};
|
||||
|
||||
use super::test_ui::load_ui as load_xml;
|
||||
|
||||
/// The window loaded **with the feature switched on** — which is not how it ships.
|
||||
///
|
||||
/// `SHOW_COMBAT_TEXT` boots at the reference's `"0"` since 1804 (it was `"1"` from 0578, when the
|
||||
/// director's ask for scrolling combat text was read as the shipped experience). The master is
|
||||
/// enforced at the source: `CombatText_UpdateDisplayedMessages` registers no events at all while
|
||||
/// it is off, so a fresh VM answers every question below with "nothing happened". These tests are
|
||||
/// about what the feature *does* once a player ticks the Combat page's box, so the harness ticks
|
||||
/// it for them exactly the way that row does — assign the global, then re-run the family's
|
||||
/// applyFunc. The gating itself is [`combat_text_master_toggle_unregisters`]'s subject, not theirs.
|
||||
fn load_combat_text() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "CombatText.xml");
|
||||
s.run("SHOW_COMBAT_TEXT = \"1\"; CombatText_UpdateDisplayedMessages()")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s
|
||||
@@ -245,13 +256,31 @@ fn combat_text_crit_peak_is_uncapped() {
|
||||
}
|
||||
|
||||
/// The master toggle: SHOW_COMBAT_TEXT = "0" + CombatText_UpdateDisplayedMessages unregisters
|
||||
/// everything — a subsequent damage event paints nothing (ref-identical gating; our default is
|
||||
/// "1", the ref ships "0" — decision 0578's named divergence).
|
||||
/// everything — a subsequent damage event paints nothing (ref-identical gating). Since 1804 that
|
||||
/// "0" is also what a fresh client ships (it was 0578's named divergence, `"1"`, until then), so
|
||||
/// the test walks the switch **down from the harness's planted ON**: it fires a damage message
|
||||
/// first and watches it paint, because "nothing painted" only means the toggle worked if
|
||||
/// something would otherwise have.
|
||||
#[test]
|
||||
fn combat_text_master_toggle_unregisters() {
|
||||
let mut s = load_combat_text();
|
||||
s.fire_event(
|
||||
"COMBAT_TEXT_UPDATE",
|
||||
vec![
|
||||
ScriptValue::Str("DAMAGE".into()),
|
||||
ScriptValue::Str("17".into()),
|
||||
],
|
||||
);
|
||||
let painted: bool = s
|
||||
.eval("return getn(COMBAT_TEXT_TO_ANIMATE) == 1 and CombatText1:IsShown() ~= nil")
|
||||
.unwrap();
|
||||
assert!(painted, "enabled: the message paints ({:?})", s.errors());
|
||||
|
||||
// The switch, then the file's own list clear — the message already in flight belongs to the
|
||||
// planted ON state, and the claim under test is about what arrives AFTER the gate closes.
|
||||
s.run("SHOW_COMBAT_TEXT = \"0\"; CombatText_UpdateDisplayedMessages()")
|
||||
.unwrap();
|
||||
s.run("CombatText_ClearAnimationList()").unwrap();
|
||||
s.fire_event(
|
||||
"COMBAT_TEXT_UPDATE",
|
||||
vec![
|
||||
|
||||
@@ -650,3 +650,55 @@ fn the_roll_template_carries_the_reference_name_and_the_parts_addons_reach_for()
|
||||
.eval::<bool>(r#"return getglobal("BenillaGroupLootFrameTemplate") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The **stock** `GroupLootFrame_OnShow` over an in-flight roll — the same defect 1805 fixed one
|
||||
/// window over, proven at the file that has not been migrated yet.
|
||||
///
|
||||
/// `GroupLootFrame1..4`, their template and `GroupLootFrame_OnShow` all live in the stock
|
||||
/// `LootFrame.xml`/`.lua`, which has been on the player's chain since 1800; our `GroupLootFrame.xml`
|
||||
/// loads after it and shadows the lot. So the stock body is dormant, not absent — and it does
|
||||
/// `local color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:275`) then `color.r` (`:276`) off
|
||||
/// `GetLootRollItemInfo`, with none of the `or ITEM_QUALITY_COLORS[1]` our copy carries.
|
||||
///
|
||||
/// This test loads the stock file WITHOUT ours, so the dormant body runs. It is the guard that stops
|
||||
/// the eventual GroupLootFrame migration from shipping the loot bug a second time.
|
||||
#[test]
|
||||
fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in super::test_ui::LOOT_UI {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
|
||||
s.set_loot_rolls(rolls());
|
||||
|
||||
// Roll 9 is the in-flight one; roll 99 does not exist at all (a stale id from a torn-down
|
||||
// frame). Both take the reference's miss tail, so both must paint rather than raise.
|
||||
for roll in [9, 99] {
|
||||
s.run(&format!("GroupLootFrame_OpenNewFrame({roll}, 55000)"))
|
||||
.unwrap_or_else(|e| panic!("stock OpenNewFrame raised on roll {roll}: {e}"));
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"stock GroupLootFrame_OnShow raised on roll {roll}: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
assert!(s
|
||||
.eval::<bool>("return GroupLootFrame1:IsVisible() and GroupLootFrame2:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
// …and the colour it painted is the miss tail's Common, read back off the FontString that
|
||||
// `:276` set — not a nil, and not the Epic of the resolved roll sitting beside it.
|
||||
let painted: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = GroupLootFrame1Name:GetTextColor()\nreturn r, g, b")
|
||||
.unwrap();
|
||||
let common: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = GetItemQualityColor(1)\nreturn r, g, b")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(painted.0 - common.0).abs() < 1e-6
|
||||
&& (painted.1 - common.1).abs() < 1e-6
|
||||
&& (painted.2 - common.2).abs() < 1e-6,
|
||||
"the stock roll popup paints the miss tail's Common, got {painted:?} want {common:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1072,3 +1072,122 @@ fn a_row_click_takes_rather_than_continues() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The state every loot on a cold item cache opens in** — the rows are on the wire, the item
|
||||
/// templates are not back yet — driven through the STOCK `LootFrame.lua`. Decision 1805.
|
||||
///
|
||||
/// The hole this closes: every loot fixture in this file (and in `benilla-ui`'s own) handed the
|
||||
/// window fully-resolved rows, so nothing in the suite ever ran `LootFrame_Update` over a row whose
|
||||
/// template was still in flight. Our deleted `LootFrame.xml` could not have noticed either — it
|
||||
/// carried an `ITEM_QUALITY_COLORS[quality] or ITEM_QUALITY_COLORS[1]` guard, the way
|
||||
/// `GroupLootFrame.xml` and `AuctionFrame.xml` still do. The stock file has no guard: it does
|
||||
/// `color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:82`) and dereferences `color.r` on the
|
||||
/// next line, so a nil quality raised
|
||||
/// `LootFrame.lua:85: attempt to index local 'color' (a nil value)` out of `LootFrame_OnShow` —
|
||||
/// on the director's screen, on an ordinary corpse.
|
||||
///
|
||||
/// The window must therefore come up clean, paint the row, and paint it in the cache-miss colour —
|
||||
/// `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s `for i = -1, 6` exists to provide.
|
||||
#[test]
|
||||
fn loot_row_awaiting_its_template_opens_clean() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in super::test_ui::LOOT_UI {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
|
||||
|
||||
// One row, in flight: the wire gave us the icon (it rides the display id) and the stack size,
|
||||
// and nothing else yet.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 2582,
|
||||
name: None,
|
||||
texture: Some("Interface\\Icons\\INV_Gauntlets_17".into()),
|
||||
quantity: 1,
|
||||
quality: None,
|
||||
is_coin: false,
|
||||
link: None,
|
||||
random_property_id: 0,
|
||||
})],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"an in-flight loot row must not raise in LootFrame_Update: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return LootFrame:IsVisible() and LootButton1:IsVisible()")
|
||||
.unwrap(),
|
||||
"the window opened and drew the row"
|
||||
);
|
||||
|
||||
// The binding answered the reference's sentinels rather than nils, and the quality one indexes
|
||||
// `ITEM_QUALITY_COLORS` — which is the whole of what `LootFrame.lua:82` needs to be true.
|
||||
let (item, quantity, quality) = s
|
||||
.eval::<(String, i64, i64)>("local _, i, n, q = GetLootSlotInfo(1)\nreturn i, n, q")
|
||||
.unwrap();
|
||||
assert_eq!((item.as_str(), quantity, quality), ("", 1, -1));
|
||||
assert!(
|
||||
s.eval::<bool>("return ITEM_QUALITY_COLORS[select(4, GetLootSlotInfo(1))] ~= nil")
|
||||
.unwrap(),
|
||||
"the cache-miss quality must be a real row of ITEM_QUALITY_COLORS"
|
||||
);
|
||||
|
||||
// And line 85 — the line that raised — actually ran: the row text wears the cache-miss colour,
|
||||
// read back off the FontString rather than off the table. `ITEM_QUALITY_COLORS[-1]` is Common
|
||||
// (`GetItemQualityColor`'s clamp is unsigned, so -1 takes the same branch as 7-and-up), not the
|
||||
// Poor it used to be here — 1805 corrects 1199 on that.
|
||||
let painted: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = LootButton1Text:GetTextColor()\nreturn r, g, b")
|
||||
.unwrap();
|
||||
let miss: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = GetItemQualityColor(-1)\nreturn r, g, b")
|
||||
.unwrap();
|
||||
assert_eq!(miss, (1.0, 1.0, 1.0), "the cache-miss row is Common/white");
|
||||
// (f32 region storage vs the f64 the binding computes — compare, don't equate.)
|
||||
assert!(
|
||||
(painted.0 - miss.0).abs() < 1e-6
|
||||
&& (painted.1 - miss.1).abs() < 1e-6
|
||||
&& (painted.2 - miss.2).abs() < 1e-6,
|
||||
"LootFrame_Update:85 must paint the -1 colour, got {painted:?} want {miss:?}"
|
||||
);
|
||||
|
||||
// The template lands and the window is reopened: the row reads its name in its own quality's
|
||||
// colour, so the sentinel state poisons nothing downstream.
|
||||
//
|
||||
// It is a REOPEN and not a repaint on purpose. The app fires `LOOT_UPDATE` when an open
|
||||
// window's content changes, and `LOOT_UPDATE` is not a 1.12 event — it appears nowhere in the
|
||||
// reference's FrameXML and `LootFrame_OnLoad` does not register it — so nothing in the stock
|
||||
// file listens. What the real client does when a template arrives mid-window is out at wow-re
|
||||
// (the arrival callback `0x4c2ac0` is recorded as clearing the pending flag and nothing else);
|
||||
// this test deliberately asserts only what is settled.
|
||||
s.fire_event("LOOT_CLOSED", vec![]);
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 2582,
|
||||
name: Some("Thin Cloth Gloves".into()),
|
||||
texture: Some("Interface\\Icons\\INV_Gauntlets_17".into()),
|
||||
quantity: 1,
|
||||
quality: Some(1),
|
||||
is_coin: false,
|
||||
link: None,
|
||||
random_property_id: 0,
|
||||
})],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s.resolve();
|
||||
let white = text_color(&s.extract(), "Thin Cloth Gloves").expect("the resolved name renders");
|
||||
assert!(
|
||||
(white[0] - 1.0).abs() < 0.02
|
||||
&& (white[1] - 1.0).abs() < 0.02
|
||||
&& (white[2] - 1.0).abs() < 0.02,
|
||||
"the resolved row is white, got {white:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,7 +670,7 @@ fn a_slider_rows_readout_sits_on_its_labels_line() {
|
||||
/// the app's boot order (register → seed → load → select).
|
||||
fn audio_harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1219,30 +1219,34 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
// The plate pair leads the page and rides `VPlateMode::default()`: enemy on (the 0167 boot
|
||||
// divergence), friendly off (faithful, 0599).
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyNameplatesRowEnemyPlatesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Enemy Name Plates defaults checked"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyNameplatesRowFriendlyPlatesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Friendly Name Plates defaults unchecked"
|
||||
);
|
||||
// All three name rows ride their registered "1" defaults.
|
||||
for row in ["RowPlayerNames", "RowNpcNames", "RowOwnName"] {
|
||||
// The plate pair leads the page and rides `VPlateMode::default()`: since 1804 both are OFF,
|
||||
// which is the reference's own boot state (its bitmask starts clear and FrameXML's
|
||||
// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` start nil). Enemy was 0167's pin until then.
|
||||
for row in ["RowEnemyPlates", "RowFriendlyPlates"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
!s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} defaults checked"
|
||||
"{row} defaults unchecked"
|
||||
);
|
||||
}
|
||||
// The name rows ride their registered defaults, which are the binary's: player "1", NPC and
|
||||
// own "0" (1804 — the last two were director pins from 2026-07-12 until then).
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Player Names defaults checked"
|
||||
);
|
||||
for row in ["RowNpcNames", "RowOwnName"] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} defaults unchecked"
|
||||
);
|
||||
}
|
||||
let _ = s.take_sounds();
|
||||
@@ -1259,12 +1263,22 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![(crate::vplates::CVAR_ENEMIES.to_string(), "0".to_string())]
|
||||
vec![(crate::vplates::CVAR_ENEMIES.to_string(), "1".to_string())]
|
||||
);
|
||||
let _ = s.take_sounds();
|
||||
|
||||
// Unchecking NPC Names queues the flag off and plays the OFF kit (no quirk here — the
|
||||
// 1.12 interface panel's PlayClickSound mapping, not the sound panel's inverted one).
|
||||
// Checking NPC Names queues the flag on and plays the ON kit; unchecking plays OFF (no quirk
|
||||
// here — the 1.12 interface panel's PlayClickSound mapping, not the sound panel's inverted
|
||||
// one).
|
||||
s.run("OptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("UnitNameNPC".to_string(), "1".to_string())]
|
||||
);
|
||||
assert!(s
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
|
||||
s.run("OptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -1274,12 +1288,6 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
assert!(s
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOff".into())));
|
||||
s.run("OptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("UnitNameNPC".to_string(), "1".to_string())]
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -2281,9 +2289,10 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
|
||||
// Read: each box shows its global, at CombatText.xml's own file-scope value.
|
||||
// Read: each box shows its global, at CombatText.xml's own file-scope value — the whole
|
||||
// family at the reference's own defaults since 1804, master included (it was "1" from 0578).
|
||||
for (row, checked) in [
|
||||
("RowCombatText", true),
|
||||
("RowCombatText", false),
|
||||
("RowAuras", true),
|
||||
("RowAuraFade", false),
|
||||
("RowDodgeParryMiss", false),
|
||||
@@ -2307,7 +2316,20 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
|
||||
);
|
||||
|
||||
// Write: the global moves, the CVar table is untouched, and the applyFunc lands.
|
||||
//
|
||||
// The master goes first, and it is a write under test in its own right — but it is also the
|
||||
// gate: every other row on this page is greyed while `SHOW_COMBAT_TEXT` is "0" (the shipped
|
||||
// state since 1804; the dependency rule is pinned next door in
|
||||
// `the_combat_master_greys_the_family_and_combo_points_is_class_gated`), and a greyed control
|
||||
// eats its click. So the two clicks below only mean anything with this one ahead of them.
|
||||
let _ = s.take_cvar_changes();
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"1",
|
||||
"the master writes its own global"
|
||||
);
|
||||
s.run("OptionsFrameContainerBodyCombatRowDodgeParryMissCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -2347,6 +2369,10 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
|
||||
/// The master's dependency rule, 1.12's verbatim: `SHOW_COMBAT_TEXT` off greys **every** other
|
||||
/// row on the page including the dropdown, and back on wakes them — except Combo Points, which
|
||||
/// carries the reference's second gate and stays greyed for anyone who is not a rogue or druid.
|
||||
///
|
||||
/// Since 1804 the page **opens** in the greyed state, because the master ships at the reference's
|
||||
/// "0" (0578's `"1"` was the pin). So the walk here is off → on → off, and the sub-rows' first
|
||||
/// wake is the master's own click rather than the load: the same rule, entered from the other end.
|
||||
#[test]
|
||||
fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
let mut s = combat_harness();
|
||||
@@ -2359,8 +2385,18 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// No player class in this VM, so Combo Points is greyed from the start while its siblings
|
||||
// The shipped state: the master is off, so the whole family is greyed on arrival — and the
|
||||
// master itself is not, because it is the way in.
|
||||
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
|
||||
assert!(!enabled(&mut s, "RowAuras", "Check"));
|
||||
assert!(!enabled(&mut s, "RowFloatMode", "DropdownButton"));
|
||||
assert!(enabled(&mut s, "RowCombatText", "Check"));
|
||||
|
||||
// On: the siblings wake. No player class in this VM, so Combo Points stays greyed while they
|
||||
// are live — the two gates are independent.
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "1");
|
||||
assert!(enabled(&mut s, "RowAuras", "Check"));
|
||||
assert!(!enabled(&mut s, "RowComboPoints", "Check"));
|
||||
assert!(enabled(&mut s, "RowFloatMode", "DropdownButton"));
|
||||
@@ -2380,14 +2416,9 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
enabled(&mut s, "RowCombatText", "Check"),
|
||||
"the master itself stays live — it is the way back"
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(enabled(&mut s, "RowAuras", "Check"));
|
||||
assert!(enabled(&mut s, "RowFloatMode", "DropdownButton"));
|
||||
assert!(
|
||||
!enabled(&mut s, "RowComboPoints", "Check"),
|
||||
"the class gate outlives the master's return"
|
||||
"the class gate outlives the master's departure too"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
@@ -2395,20 +2426,26 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
/// Defaults on a saved-variable page restores each global to **the value its own XML assigned at
|
||||
/// file scope** — captured at the row's OnLoad, which the load order guarantees runs before the
|
||||
/// saved chunk (1128). The panel restates no defaults of its own, so it cannot drift from
|
||||
/// CombatText.xml the way the reference's hand-copied `default` field can.
|
||||
/// CombatText.xml the way the reference's hand-copied `default` field can. Since 1804 that
|
||||
/// assignment is the reference's own value for all fourteen, so this is now also the check that
|
||||
/// Defaults lands a player back on a stock 1.12 combat-text family.
|
||||
#[test]
|
||||
fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
|
||||
let s = combat_harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
|
||||
// Move three of them off their shipped values, in both directions and both flavors.
|
||||
// Move FOUR of them off their shipped values, in both directions and both flavors. The master
|
||||
// leads because the page ships with it off (1804) and a greyed row eats its click — so it is
|
||||
// both the way in and the fourth value Defaults has to walk back.
|
||||
s.run(
|
||||
"OptionsFrameContainerBodyCombatRowAurasCheck:Click() \
|
||||
"OptionsFrameContainerBodyCombatRowCombatTextCheck:Click() \
|
||||
OptionsFrameContainerBodyCombatRowAurasCheck:Click() \
|
||||
OptionsFrameContainerBodyCombatRowReputationCheck:Click() \
|
||||
OptionsFrameContainerBodyCombatRowFloatModeDropdownButton:Click() \
|
||||
DropDownList1Button3:Click()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "1");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
|
||||
"0"
|
||||
@@ -2424,6 +2461,11 @@ fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"0",
|
||||
"the master walks back too — the reference's own value since 1804"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
|
||||
"1"
|
||||
@@ -2456,6 +2498,11 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
let s = combat_harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
|
||||
// The master first: the page ships with it off (1804) and a greyed row eats its click, so
|
||||
// without this the Honor Gained click below would be a silent no-op. Both writes are then
|
||||
// held to the same standard — they have to come back after the restart.
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
s.run("OptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
|
||||
.unwrap();
|
||||
|
||||
@@ -2464,6 +2511,10 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
|
||||
"the toggle is in the saved text:\n{saved}"
|
||||
);
|
||||
assert!(
|
||||
saved.contains("SHOW_COMBAT_TEXT = \"1\""),
|
||||
"and so is the master:\n{saved}"
|
||||
);
|
||||
|
||||
// The restart: a fresh tree at its shipped defaults, then the saved chunk over the top.
|
||||
let fresh = combat_harness();
|
||||
@@ -2473,6 +2524,10 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"0"
|
||||
);
|
||||
fresh.run(&saved).unwrap();
|
||||
assert_eq!(
|
||||
fresh
|
||||
@@ -2481,6 +2536,11 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
"0",
|
||||
"the saved value wins over the file-scope default"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"1",
|
||||
"…and so does the master the player turned on"
|
||||
);
|
||||
// And the page paints the restored value the next time it is opened.
|
||||
fresh.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
fresh
|
||||
@@ -2582,15 +2642,22 @@ fn the_interface_page_writes_the_three_stock_globals() {
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
|
||||
// Read: all three ship on — QuestFrame.xml's "1" (the director's pin), QuestLogFrame.xml's "1"
|
||||
// (the reference's advertised default, as a STRING — see that file on 1.12's own number/string
|
||||
// break), GameTooltip.xml's "1".
|
||||
for row in ["RowInstantQuestText", "RowAutoQuestWatch", "RowNewbieTips"] {
|
||||
assert!(
|
||||
// Read: each box shows its definer's own file-scope value. Two ship on — QuestLogFrame.xml's
|
||||
// "1" (the reference's advertised default, as a STRING — see that file on 1.12's own
|
||||
// number/string break) and GameTooltip.xml's "1" — and Instant Quest Text ships OFF, which is
|
||||
// QuestFrame.xml's `QUEST_FADING_DISABLE = "0"`: the reference's value, and ours since 1804
|
||||
// (it was pinned "1" by direction on 2026-07-17).
|
||||
for (row, checked) in [
|
||||
("RowInstantQuestText", false),
|
||||
("RowAutoQuestWatch", true),
|
||||
("RowNewbieTips", true),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyInterface{row}Check:GetChecked() and true or false"
|
||||
))
|
||||
.unwrap(),
|
||||
checked,
|
||||
"{row} reads its global"
|
||||
);
|
||||
}
|
||||
@@ -2609,12 +2676,12 @@ fn the_interface_page_writes_the_three_stock_globals() {
|
||||
);
|
||||
|
||||
// No applyFunc, because the write IS the apply: the questgiver's fade arm reads the global as
|
||||
// the panel shows, so turning instant text OFF makes the very next show write on.
|
||||
// the panel shows, so turning instant text ON makes the very next show land its text whole.
|
||||
s.run("OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
|
||||
"0"
|
||||
"1"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
@@ -2843,12 +2910,18 @@ fn defaults_sends_a_flip_only_for_the_row_that_moved() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Defaults here restores **our** shipped assignments, which is the visible payoff of capturing the
|
||||
/// default at OnLoad instead of restating it (1134 §1): `QUEST_FADING_DISABLE` ships `"1"` by
|
||||
/// direction (2026-07-17) where 1.12's own table hand-writes `default = "0"`. The page walks back
|
||||
/// to the value QuestFrame.xml actually assigns — there is no second copy to disagree with it.
|
||||
/// Defaults here restores the value **each global's own definer file assigns**, which is the
|
||||
/// visible payoff of capturing the default at OnLoad instead of restating it (1134 §1): the page
|
||||
/// holds no second copy that could disagree with QuestFrame.xml or GameTooltip.xml.
|
||||
///
|
||||
/// This test was `defaults_on_the_interface_page_restores_our_pin_not_the_references` until 1804,
|
||||
/// and its premise is now inverted. `QUEST_FADING_DISABLE` shipped `"1"` by direction
|
||||
/// (2026-07-17) where 1.12's own table hand-writes `default = "0"`, so Defaults used to walk the
|
||||
/// page back to a value the reference did not have. It walks back to `"0"` now — not because the
|
||||
/// mechanism changed, but because the assignment it reads **is** the reference's. That is the
|
||||
/// property worth holding: the capture follows the definer wherever the definer stands.
|
||||
#[test]
|
||||
fn defaults_on_the_interface_page_restores_our_pin_not_the_references() {
|
||||
fn defaults_on_the_interface_page_restores_the_definers_own_assignment() {
|
||||
let s = interface_harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowInterface:Click()")
|
||||
@@ -2860,18 +2933,18 @@ fn defaults_on_the_interface_page_restores_our_pin_not_the_references() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
|
||||
"0"
|
||||
"1"
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "0");
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
|
||||
"1",
|
||||
"back to OUR default, not the reference's \"0\""
|
||||
"0",
|
||||
"back to QuestFrame.xml's own assignment, which is the reference's \"0\""
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "1");
|
||||
assert!(s
|
||||
assert!(!s
|
||||
.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:GetChecked() \
|
||||
and true or false"
|
||||
@@ -2929,16 +3002,23 @@ fn what_the_interface_page_writes_survives_a_restart() {
|
||||
|
||||
/// **A saved value that has a SIDE EFFECT has to be applied when the chunk lands.** The whole UI's
|
||||
/// XML runs before `benilla-config/saved-variables.lua` executes over it (1128), so every file-scope
|
||||
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` armed its six event
|
||||
/// registrations from `SHOW_COMBAT_TEXT = "1"`, and nothing re-ran when the saved `"0"` replaced it.
|
||||
/// The player's "off" came back on at every restart. 1.12 closes this with a hand-written ladder in
|
||||
/// `UIOptionsFrame`'s `VARIABLES_LOADED` arm ("Option specific function calls", UIOptionsFrame.lua
|
||||
/// l.204-220); we hold each side effect on its own row already, so the window runs them there.
|
||||
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` decides its six
|
||||
/// event registrations from `SHOW_COMBAT_TEXT` once, at load, and nothing re-runs when the saved
|
||||
/// value replaces it. The player's choice came back undone at every restart. 1.12 closes this with
|
||||
/// a hand-written ladder in `UIOptionsFrame`'s `VARIABLES_LOADED` arm ("Option specific function
|
||||
/// calls", UIOptionsFrame.lua l.204-220); we hold each side effect on its own row already, so the
|
||||
/// window runs them there.
|
||||
///
|
||||
/// **The saved value here is `"1"`, and the direction is the whole point.** Until 1804 the master
|
||||
/// shipped `"1"`, so the bug was a saved `"0"` coming back ON and the test drove the switch down.
|
||||
/// The master ships at the reference's `"0"` now, so driving it down would assert nothing — the
|
||||
/// file-scope default already leaves the six events unregistered. The load-bearing case is the
|
||||
/// other one: the registrations were never armed at all, and only the walk can arm them.
|
||||
#[test]
|
||||
fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
|
||||
let mut s = combat_harness();
|
||||
// What the saved chunk does, verbatim: assign over the file-scope default, then the event.
|
||||
s.run("SHOW_COMBAT_TEXT = \"0\"").unwrap();
|
||||
s.run("SHOW_COMBAT_TEXT = \"1\"").unwrap();
|
||||
s.fire_event("VARIABLES_LOADED", vec![]);
|
||||
|
||||
s.fire_event(
|
||||
@@ -2949,9 +3029,9 @@ fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return getn(COMBAT_TEXT_TO_ANIMATE) == 0")
|
||||
s.eval::<bool>("return getn(COMBAT_TEXT_TO_ANIMATE) == 1")
|
||||
.unwrap(),
|
||||
"the saved-off master is armed at load: the registrations were never re-run"
|
||||
"the saved-on master is applied at load: the walk armed the registrations the XML did not"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
@@ -2962,6 +3042,12 @@ fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
|
||||
/// timers the buff bar's three rows sit 45px apart, without them 35px — the reference's own two
|
||||
/// geometries (`BuffButtons_UpdatePositions`). The click has to move it, and so does the saved
|
||||
/// value landing at load, which is what the VARIABLES_LOADED walk is for.
|
||||
///
|
||||
/// The walk runs OFF → ON, because the bar ships without timers: `SHOW_BUFF_DURATIONS` is the
|
||||
/// reference's `"0"` since 1804. It shipped `"1"` from 0255, which put the durations-shown
|
||||
/// geometry in on the director's call, through 1139, which turned it into a setting without
|
||||
/// re-weighing what it ships as. Nothing about the two geometries or the apply hook changed —
|
||||
/// only which of them a fresh install opens on.
|
||||
#[test]
|
||||
fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
let gap = |s: &mut UiScript| -> f64 {
|
||||
@@ -2971,17 +3057,17 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
};
|
||||
|
||||
let mut s = interface_harness();
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "1");
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "0");
|
||||
assert!(
|
||||
(gap(&mut s) - 15.0).abs() < 1e-3,
|
||||
"the shipped default is the durations-SHOWN geometry: {}",
|
||||
(gap(&mut s) - 5.0).abs() < 1e-3,
|
||||
"the shipped default is the durations-HIDDEN geometry: {}",
|
||||
gap(&mut s)
|
||||
);
|
||||
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowInterface:Click()")
|
||||
.unwrap();
|
||||
assert!(s
|
||||
assert!(!s
|
||||
.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:GetChecked() \
|
||||
and true or false"
|
||||
@@ -2990,10 +3076,10 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
|
||||
s.run("OptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "0");
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "1");
|
||||
assert!(
|
||||
(gap(&mut s) - 5.0).abs() < 1e-3,
|
||||
"the click closed the timer gutter: {}",
|
||||
(gap(&mut s) - 15.0).abs() < 1e-3,
|
||||
"the click opened the timer gutter: {}",
|
||||
gap(&mut s)
|
||||
);
|
||||
|
||||
@@ -3001,18 +3087,18 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
// VARIABLES_LOADED is what puts the bar where the value says.
|
||||
let saved = s.saved_variables_text();
|
||||
assert!(
|
||||
saved.contains("SHOW_BUFF_DURATIONS = \"0\""),
|
||||
saved.contains("SHOW_BUFF_DURATIONS = \"1\""),
|
||||
"the switch is in the saved text:\n{saved}"
|
||||
);
|
||||
let mut fresh = interface_harness();
|
||||
fresh.run(&saved).unwrap();
|
||||
assert!(
|
||||
(gap(&mut fresh) - 15.0).abs() < 1e-3,
|
||||
(gap(&mut fresh) - 5.0).abs() < 1e-3,
|
||||
"the chunk moved the variable, not the bar"
|
||||
);
|
||||
fresh.fire_event("VARIABLES_LOADED", vec![]);
|
||||
assert!(
|
||||
(gap(&mut fresh) - 5.0).abs() < 1e-3,
|
||||
(gap(&mut fresh) - 15.0).abs() < 1e-3,
|
||||
"the apply walk re-derives the geometry from the saved value: {}",
|
||||
gap(&mut fresh)
|
||||
);
|
||||
@@ -3087,9 +3173,9 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
|
||||
/// say/yell bubbles off).
|
||||
#[test]
|
||||
fn the_chat_page_toggles_the_chat_bubble_cvars() {
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("ChatBubblesParty", "0");
|
||||
let mut s = harness_on(s);
|
||||
// No host override: since 1804 the registered pair IS bubbles-on / party-off, so the page's
|
||||
// read below is of the shipped table rather than of a value this test planted.
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
|
||||
|
||||
@@ -3129,13 +3215,14 @@ fn the_chat_page_toggles_the_chat_bubble_cvars() {
|
||||
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
|
||||
.unwrap());
|
||||
|
||||
// Defaults walks the page back to the registered pair — including the ChatBubblesParty "1"
|
||||
// that deliberately disagrees with the binary's registered "0" (the director's /p ask, 0598).
|
||||
// Defaults walks the page back to the registered pair, which is the binary's own since 1804:
|
||||
// `ChatBubbles` "1", `ChatBubblesParty` "0" (the party half was the director's /p ask, 0598).
|
||||
// The party row was clicked ON above, so this is a real walk-back, not a no-op.
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
assert!(!s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
@@ -3364,8 +3451,12 @@ fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
|
||||
/// **Max Camera Distance** (decision 1140) — the fourth frozen constant, and the one with a wrinkle
|
||||
/// worth pinning: 1.12 stores a FACTOR over `cameraDistanceMax`'s 15 yd base, so the value that
|
||||
/// persists is `1.0 … 2.0` while the thing the player is choosing is a distance. The readout shows
|
||||
/// the distance; the CVar carries the factor. Our default is the factor fully raised — the 30 yd
|
||||
/// ceiling benilla has always had — where the reference's registrar ships 1.0.
|
||||
/// the distance; the CVar carries the factor.
|
||||
///
|
||||
/// The slider's RANGE is unchanged and still reaches the 30 yd ceiling benilla used to ship at;
|
||||
/// what moved is where it rests. Since 1804 the default is the registrar's own `1.0` — 15 yd, the
|
||||
/// distance a fresh 1.12 client stops the wheel at — so the walk here is up the range and back
|
||||
/// rather than down it.
|
||||
#[test]
|
||||
fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
@@ -3379,15 +3470,46 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"30 yd"
|
||||
"15 yd",
|
||||
"vanilla's own out-of-box ceiling"
|
||||
);
|
||||
|
||||
// Down to the reference's own default: the factor is what persists, the yards are the label.
|
||||
s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(1.0)")
|
||||
// A move off the notch: the factor is what persists, the yards are the label — and the two
|
||||
// disagree numerically, which is why a mid-range stop is worth a step of its own.
|
||||
s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(1.4)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("cameraDistanceMaxFactor".to_string(), "1".to_string())]
|
||||
vec![("cameraDistanceMaxFactor".to_string(), "1.4".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"21 yd"
|
||||
);
|
||||
|
||||
// All the way up — the slider's top, which is where this row's default sat until 1804.
|
||||
s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(2.0)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("cameraDistanceMaxFactor".to_string(), "2".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"30 yd"
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"cameraDistanceMaxFactor\")")
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
@@ -3395,14 +3517,7 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
)
|
||||
.unwrap(),
|
||||
"15 yd",
|
||||
"vanilla's own out-of-box ceiling"
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"cameraDistanceMaxFactor\")")
|
||||
.unwrap(),
|
||||
"2"
|
||||
"the readout follows the reset"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ fn hovered_pet_bar() -> UiScript {
|
||||
// them — so `GetBindingKey("BONUSACTIONBUTTON1")` answers CTRL-1 (its byte-real default) and
|
||||
// `GetCVar("UberTooltips")` answers "1" (its byte-read registrar value) rather than nil.
|
||||
s.register_bindings(&crate::bindings::registry_commands());
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
load_pet_bar_with_tooltip(&s);
|
||||
declare_token_strings(&s);
|
||||
s.set_pet_actions(true, true, true, hunter_slots());
|
||||
|
||||
@@ -330,6 +330,12 @@ fn greeting_goodbye_button_closes_the_window() {
|
||||
/// is a virtual template declared `hidden="true"` (never itself drawn) — an instance that doesn't
|
||||
/// explicitly `:Show()` stays invisible even though it isn't declared `hidden="true"` itself. Every
|
||||
/// panel's action buttons must show, extracted at real on-window rects (never dropped/hidden).
|
||||
///
|
||||
/// It runs the **instant-text** arm, which is not the shipped one: `QUEST_FADING_DISABLE` boots at
|
||||
/// the reference's `"0"` since 1804 (it was pinned `"1"` by direction from 2026-07-17 until then),
|
||||
/// so the flag is planted below. That arm is the one this guard wants — it is the shorter path to
|
||||
/// a settled panel, and its one-frame Accept-disabled window is a state the rects have to survive.
|
||||
/// The write-on arm is `write_on_still_fades_when_instant_text_is_off`'s subject.
|
||||
#[test]
|
||||
fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -340,6 +346,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
// Planted, not shipped: instant text is OFF out of the box (see the doc comment).
|
||||
s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap();
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Detail,
|
||||
title: "A Threat Within".into(),
|
||||
@@ -374,8 +382,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
v
|
||||
};
|
||||
|
||||
// The instant-text arm (QUEST_FADING_DISABLE pinned "1"): OnShow still disables Accept and
|
||||
// zeroes the block for the ref's one-frame window — the reveal edge just starts at 1024.
|
||||
// The instant-text arm (QUEST_FADING_DISABLE planted "1" above): OnShow still disables Accept
|
||||
// and zeroes the block for the ref's one-frame window — the reveal edge just starts at 1024.
|
||||
let writing = button_art(&mut s);
|
||||
assert_eq!(
|
||||
writing.len(),
|
||||
@@ -435,10 +443,11 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The ref write-on survives verbatim behind the flag: with QUEST_FADING_DISABLE = "0" (the
|
||||
/// ref's own default, our pin is "1"), the description writes on at 40 chars/s scratching the
|
||||
/// quill each tick, Accept stays dead mid-write, and the objectives/rewards block FADES in over
|
||||
/// QUESTINFO_FADE_IN rather than snapping.
|
||||
/// The ref write-on runs verbatim: with QUEST_FADING_DISABLE = "0" — the ref's own default, and
|
||||
/// ours too since 1804 (it was pinned "1" from 2026-07-17 until then, which is why this file
|
||||
/// still says the value out loud rather than leaning on the shipped one) — the description writes
|
||||
/// on at 40 chars/s scratching the quill each tick, Accept stays dead mid-write, and the
|
||||
/// objectives/rewards block FADES in over QUESTINFO_FADE_IN rather than snapping.
|
||||
#[test]
|
||||
fn write_on_still_fades_when_instant_text_is_off() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -123,7 +123,7 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
|
||||
fn seat_a_hunter(root: &Path) -> UiScript {
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.set_realm_name("Harness");
|
||||
s.set_unit(
|
||||
"player",
|
||||
|
||||
@@ -152,6 +152,105 @@ fn is_word(c: char) -> bool {
|
||||
mod tests {
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
/// **Every saved UI global we declare defaults to what the reference's own file declares**
|
||||
/// (decision 1804) — the FrameXML half of the "a default is the reference's" standard that
|
||||
/// `cvars::REGISTERED`'s [`crate::cvars::Reference`] column holds for the CVar half.
|
||||
///
|
||||
/// Two stores carry a player's settings in this client and the standard has to cover both.
|
||||
/// CVars are the engine's, and their table now states the reference's value per row. The other
|
||||
/// store is FrameXML's own: the `RegisterForSave`'d globals — *Instant Quest Text*, *Show Buff
|
||||
/// Durations*, *Lock Action Bar* — whose default is a plain assignment in whichever of our
|
||||
/// `assets/ui` files still owns that window. Nothing tied those to anything, and two had
|
||||
/// drifted: `QUEST_FADING_DISABLE` and `SHOW_BUFF_DURATIONS` both shipped `"1"` where 1.12
|
||||
/// ships `"0"`, each a reasonable call on its own day (2026-07-17 and 0255) and neither
|
||||
/// visible as a *divergence from the reference* without opening its file.
|
||||
///
|
||||
/// The reference's declarations are read off the player's own chain rather than copied here,
|
||||
/// so this cannot rot the way a transcribed list would: `UIOptionsFrame.lua`'s
|
||||
/// `UIOptionsFrame_Init` assigns every options-panel uvar its factory value, and that file is
|
||||
/// the authority. A name we persist that the reference declares **somewhere else**
|
||||
/// (`SHOW_OFFLINE_GUILD_MEMBERS` lives in `FriendsFrame.lua`) or not at all (our own
|
||||
/// `TRAINER_FILTER_*`) is reported as uncovered, not failed — and the covered count is
|
||||
/// asserted, so the day our last options window migrates and this covers nothing, it says so
|
||||
/// instead of passing vacuously.
|
||||
///
|
||||
/// **This test retires as `assets/ui` does** (1751): a migrated window runs the reference's
|
||||
/// own file, whose assignment IS the reference's value, and the question stops existing.
|
||||
///
|
||||
/// Skips without client data, like every other test that reads the install.
|
||||
#[test]
|
||||
fn our_saved_ui_globals_default_to_the_references_own_values() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
|
||||
// The reference's own factory assignments, parsed out of the file that makes them. Only
|
||||
// `NAME = "literal"` / `NAME = number` at the head of a line — an assignment guarded by an
|
||||
// `if`, or one that copies another global, is not a factory default and must not be read
|
||||
// as one.
|
||||
let src = String::from_utf8_lossy(
|
||||
&super::read("Interface\\FrameXML\\UIOptionsFrame.lua")
|
||||
.expect("the reference's own UIOptionsFrame.lua"),
|
||||
)
|
||||
.into_owned();
|
||||
let mut theirs = std::collections::BTreeMap::new();
|
||||
for line in src.lines() {
|
||||
let line = line.trim();
|
||||
let Some((name, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim();
|
||||
if name.is_empty() || !name.chars().all(|c| super::is_word(c) && !c.is_lowercase()) {
|
||||
continue; // a uvar is SHOUTED; anything else is a local, a field or a comparison
|
||||
}
|
||||
let value = value.trim().trim_end_matches(';').trim();
|
||||
let literal = value.strip_prefix('"').and_then(|v| v.strip_suffix('"'));
|
||||
let Some(v) = literal.or_else(|| value.parse::<i64>().ok().map(|_| value)) else {
|
||||
continue; // not a literal — an expression, so not a factory default
|
||||
};
|
||||
theirs.insert(name.to_string(), v.to_string());
|
||||
}
|
||||
assert!(
|
||||
theirs.len() > 20,
|
||||
"parsed only {} declarations out of UIOptionsFrame.lua — the parse is broken, not the \
|
||||
reference",
|
||||
theirs.len()
|
||||
);
|
||||
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let failures = super::super::manifest::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "the default UI: {failures:#?}");
|
||||
|
||||
let (mut checked, mut uncovered, mut wrong) = (0usize, Vec::new(), Vec::new());
|
||||
for name in s.saved_variable_names() {
|
||||
let Some(want) = theirs.get(&name) else {
|
||||
uncovered.push(name);
|
||||
continue;
|
||||
};
|
||||
// Through `tostring`, because the reference is inconsistent about it itself:
|
||||
// `AUTO_QUEST_WATCH` is declared as the number 1 and every other uvar as a string.
|
||||
let got = s
|
||||
.eval::<String>(&format!("return tostring({name})"))
|
||||
.unwrap_or_else(|e| panic!("{name} is registered for save but unreadable: {e}"));
|
||||
checked += 1;
|
||||
if &got != want {
|
||||
wrong.push(format!("{name}: ours {got:?}, the reference's {want:?}"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
wrong.is_empty(),
|
||||
"saved UI globals that do not default to the reference's own value — either match it, \
|
||||
or make the divergence explicit at the assignment the way `cvars::REGISTERED` does:\n \
|
||||
{}",
|
||||
wrong.join("\n "),
|
||||
);
|
||||
assert!(
|
||||
checked >= 9,
|
||||
"only {checked} of our saved globals are declared in the reference's \
|
||||
UIOptionsFrame.lua (uncovered: {uncovered:?}) — if a window migrated, lower this; if \
|
||||
the parse broke, fix it",
|
||||
);
|
||||
}
|
||||
|
||||
/// **A chain entry really loads off the player's install, and order really decides.**
|
||||
///
|
||||
/// The two halves of this module's rule, asserted rather than described, because nothing else
|
||||
|
||||
@@ -185,7 +185,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
|
||||
#[test]
|
||||
fn action_button_hover_takes_the_default_corner() {
|
||||
let mut s = harness(&["Cooldown.xml", "ActionBar.xml"]);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
|
||||
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
|
||||
let ok: bool = s
|
||||
@@ -214,7 +214,7 @@ fn action_button_hover_takes_the_default_corner() {
|
||||
#[test]
|
||||
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
|
||||
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "MultiBars.xml"]);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.set_cvar_engine("UberTooltips", "0");
|
||||
|
||||
let seat = |s: &UiScript| {
|
||||
@@ -271,7 +271,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
|
||||
#[test]
|
||||
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
|
||||
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "StanceBar.xml"]);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.set_shapeshift_forms(vec![benilla_ui::script::ShapeshiftFormView {
|
||||
spell_id: 5487,
|
||||
name: "Bear Form".into(),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//!
|
||||
//! The pinned law, transcribed:
|
||||
//! - **Master toggles** (`[0xc4da34]` bit 0 enemy / bit 3 friendly; real-client boot default
|
||||
//! **OFF** — ours boots enemy ON, friendly OFF (faithful): director calls, 0167 + 0599,
|
||||
//! [`VPlateMode::default`]): the `ShowNameplates`/`ShowFriendNameplates` script pairs —
|
||||
//! **OFF for both**, and since 1804 ours too — [`VPlateMode::default`]): the
|
||||
//! `ShowNameplates`/`ShowFriendNameplates` script pairs —
|
||||
//! bound to **V / Shift-V** by FrameXML `Bindings.xml` (asset-sourced default, like TAB).
|
||||
//! - **Gate**: never the own unit; never `NOT_SELECTABLE` (UNIT_FIELD_FLAGS bit 25); the
|
||||
//! enemy/friendly split is **`CanAttack(localPlayer → unit)`**, NOT a reaction threshold
|
||||
@@ -94,12 +94,12 @@ use benilla_world::view::WorldCamera;
|
||||
/// the plate's larger size instead of bilinear-magnified soft (0188).
|
||||
mod border;
|
||||
|
||||
/// The master bitmask (`[0xc4da34]`): bit 0 enemy plates, bit 3 friendly. The real client boots
|
||||
/// with both OFF. Ours boots **enemy ON** (the 0167 director call — combat plates from the first
|
||||
/// frame, a deliberate deviation) and **friendly OFF** (faithful — restored by the 0599 director
|
||||
/// call, which also gives friendly speech bubbles room: the faithful plate-blocks-bubble gate
|
||||
/// would otherwise suppress them). Both stay V / Shift-V togglable. `pub(crate)` so the capture
|
||||
/// harness can force plates on for the `vplates` scenario.
|
||||
/// The master bitmask (`[0xc4da34]`): bit 0 enemy plates, bit 3 friendly. **Both boot OFF, the
|
||||
/// real client's own state** — see the derivation below. Enemy plates booted ON here from the 0167
|
||||
/// director call (combat plates from the first frame) until 1804; friendly has been OFF since the
|
||||
/// 0599 call, which also gives friendly speech bubbles room, because the faithful
|
||||
/// plate-blocks-bubble gate would otherwise suppress them. Both stay V / Shift-V togglable.
|
||||
/// `pub(crate)` so the capture harness can force plates on for the `vplates` scenario.
|
||||
///
|
||||
/// **This resource is the truth; the CVars are its persistence** (the [`CVAR_ENEMIES`] /
|
||||
/// [`CVAR_FRIENDS`] pair, [`crate::cvars`]). That mirrors the reference's own two-store shape: the
|
||||
@@ -109,7 +109,18 @@ mod border;
|
||||
/// no such string exists in the binary), so the names are the LATER-era engine's, the same posture
|
||||
/// as `autoLootDefault` — benilla's persistence lives in the CVar store (0954), and a setting with
|
||||
/// no 1.12 CVar takes the era name rather than inventing one.
|
||||
#[derive(Resource)]
|
||||
///
|
||||
/// **Both boot OFF — the reference's own state**, and verified on both of its halves, because
|
||||
/// either alone would be half an answer: the engine bitmask `[0xc4da34]` starts clear, and
|
||||
/// FrameXML's `UIOptionsFrame_Init` sets `NAMEPLATES_ON = nil` / `FRIENDNAMEPLATES_ON = nil` and
|
||||
/// only calls `ShowNameplates()` when the SAVED value is truthy (the install's own
|
||||
/// `UIOptionsFrame.lua` l.180-183, l.769-775). So a fresh 1.12 client draws no plates until you
|
||||
/// press V. Enemy plates shipped ON here from 0167 until 1804 — the same class of call as the
|
||||
/// overhead-name pair (`nameplates::NameConfig`): useful while the feature was being built, never
|
||||
/// re-weighed as a default. The `Default` is derived now that the claim is "both false", and it is
|
||||
/// still a *claim*: `cvars::tests` welds it to the registered `nameplateShowEnemies`/`Friendly`
|
||||
/// defaults and asserts the pair is off in as many words, so the derive cannot drift quietly.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct VPlateMode {
|
||||
pub(crate) enemies: bool,
|
||||
pub(crate) friends: bool,
|
||||
@@ -121,15 +132,6 @@ pub(crate) struct VPlateMode {
|
||||
pub(crate) const CVAR_ENEMIES: &str = benilla_ui::script::CVAR_NAMEPLATE_ENEMIES;
|
||||
pub(crate) const CVAR_FRIENDS: &str = benilla_ui::script::CVAR_NAMEPLATE_FRIENDS;
|
||||
|
||||
impl Default for VPlateMode {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enemies: true,
|
||||
friends: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The units carrying a live plate this frame — the ShouldShowName exclusivity verdict the
|
||||
/// overhead-name driver reads (a plated unit never also draws its floating name).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -1113,28 +1115,45 @@ mod tests {
|
||||
/// what dirties `config.toml` and what the Nameplates page's checkbox reads next time it
|
||||
/// opens. Without the mirror, plates toggled by key would silently revert at every launch and
|
||||
/// the window would show the wrong state.
|
||||
///
|
||||
/// The table is seeded at `"0"/"0"` because that is what the app seeds it with: both plate
|
||||
/// CVars boot at the reference's OFF since 1804 ([`VPlateMode::default`]), so the first V of a
|
||||
/// session turns plates ON. The mirror is direction-blind, and the second press below is here
|
||||
/// to say so.
|
||||
#[test]
|
||||
fn the_v_key_mirrors_into_the_cvar_table() {
|
||||
use crate::bindings::{cmd, BindingsState};
|
||||
let mut app = App::new();
|
||||
let mut script = benilla_ui::script::UiScript::new().unwrap();
|
||||
script.register_cvars([(CVAR_ENEMIES, "1"), (CVAR_FRIENDS, "0")]);
|
||||
script.register_cvars([(CVAR_ENEMIES, "0"), (CVAR_FRIENDS, "0")]);
|
||||
app.add_systems(Update, toggle_vplates)
|
||||
.init_resource::<VPlateMode>()
|
||||
.insert_non_send_resource(script)
|
||||
.insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES]));
|
||||
app.update();
|
||||
assert!(!app.world().resource::<VPlateMode>().enemies, "V turns off");
|
||||
assert!(app.world().resource::<VPlateMode>().enemies, "V turns on");
|
||||
let mut script = app
|
||||
.world_mut()
|
||||
.non_send_resource_mut::<benilla_ui::script::UiScript>();
|
||||
assert_eq!(
|
||||
script.take_cvar_changes(),
|
||||
vec![(CVAR_ENEMIES.to_string(), "0".to_string())],
|
||||
vec![(CVAR_ENEMIES.to_string(), "1".to_string())],
|
||||
"the change queues, so the host dirties the config file"
|
||||
);
|
||||
assert_eq!(script.cvar(CVAR_FRIENDS).as_deref(), Some("0"), "untouched");
|
||||
|
||||
// And back: the same key mirrors the OFF as an engine write too.
|
||||
app.world_mut()
|
||||
.insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES]));
|
||||
app.update();
|
||||
assert!(!app.world().resource::<VPlateMode>().enemies, "V turns off");
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
.take_cvar_changes(),
|
||||
vec![(CVAR_ENEMIES.to_string(), "0".to_string())]
|
||||
);
|
||||
|
||||
// Shift-V is the other bit, and only the other bit.
|
||||
app.world_mut()
|
||||
.insert_resource(BindingsState::test_fired(&[cmd::FRIEND_NAMEPLATES]));
|
||||
|
||||
@@ -490,19 +490,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// the table is FrameXML's and the verb underneath is the engine's. 23 corpus addons call it,
|
||||
// and three of our own XML files carry a private copy of the palette this replaces.
|
||||
//
|
||||
// **The clamp is the reference's, not a guess**: the accessor `0x52ad70` clamps `quality >= 7`
|
||||
// to index **1** (Common) — not to 6, not to an error — so an addon passing a quality from a
|
||||
// later client gets white rather than a nil it will then concatenate.
|
||||
// **The clamp is the reference's, not a guess** — and it is ONE clamp, not two, because the
|
||||
// comparison is UNSIGNED. `0x52ad70`/`0x52ad90` both do `cmp ecx,7` / `jb`, so anything at or
|
||||
// above 7 selects index **1** (Common) — not 6, not an error — and a NEGATIVE quality takes the
|
||||
// same branch, because as a `u32` it is at or above 7 too. `GetItemQualityColor(-1)` is
|
||||
// therefore **white**, and so is `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s
|
||||
// `for i = -1, 6` builds.
|
||||
//
|
||||
// Negative qualities are the other end, and the reference does not clamp them at all:
|
||||
// `UIParent.lua`'s own loop starts at `-1`, which reads *before* the array. We answer index 0
|
||||
// (Poor) there. That is the one place this binding is deliberately not bit-faithful, because
|
||||
// the faithful answer is an out-of-bounds read.
|
||||
// This corrects 1199, which called the negative arm "the one place this binding is deliberately
|
||||
// not bit-faithful … because the faithful answer is an out-of-bounds read" and answered Poor
|
||||
// (grey). There is no out-of-bounds read: the unsigned compare catches it first. The value
|
||||
// stopped being academic when the loot window went to the chain — `-1` is what
|
||||
// `GetLootSlotInfo` answers for a row whose item template is not cached, and that row's text
|
||||
// colour is this table's `-1` row (wow-re `system/ui/scratch/loot-slot-record.md` §10,
|
||||
// decision 1805).
|
||||
lua.globals().set(
|
||||
"GetItemQualityColor",
|
||||
lua.create_function(|lua, quality: i64| {
|
||||
let i = match quality {
|
||||
q if q < 0 => 0,
|
||||
// The reference reads its argument as a 32-bit int (`ftol`) and then compares it
|
||||
// unsigned, which is exactly this pair of casts.
|
||||
let i = match quality as i32 as u32 {
|
||||
q if q >= 7 => 1,
|
||||
q => q as usize,
|
||||
};
|
||||
@@ -1096,8 +1103,11 @@ mod quality_color_tests {
|
||||
// and never to nil, because the caller concatenates the result.
|
||||
assert_eq!(hex(7), "|cffffffff");
|
||||
assert_eq!(hex(99), "|cffffffff");
|
||||
// ...and a negative reads Poor here rather than reproducing the reference's out-of-bounds
|
||||
// read. `UIParent.lua`'s own loop starts at -1, so this path is exercised by real FrameXML.
|
||||
assert_eq!(hex(-1), "|cff9d9d9d");
|
||||
// …and the compare is UNSIGNED, so every negative takes the SAME branch and reads Common.
|
||||
// This is the row `UIParent.lua`'s `for i = -1, 6` builds, and the colour a loot row wears
|
||||
// while its item template is uncached (`GetLootSlotInfo` answers -1 there). It read Poor
|
||||
// until 1805; there is no out-of-bounds read to reproduce, the `jb` catches it first.
|
||||
assert_eq!(hex(-1), "|cffffffff");
|
||||
assert_eq!(hex(-99), "|cffffffff");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,21 +50,60 @@ use super::Model;
|
||||
/// length, so it cannot drift.
|
||||
pub(super) const REG_LOOTBUTTON_METHODS: &str = "__benilla_lootbutton_methods";
|
||||
|
||||
/// The **item-cache miss** quality `GetLootSlotInfo` answers for a row whose item template has not
|
||||
/// landed yet — the reference's own sentinel, and emphatically **not** a nil. `0x4c23a0` reads the
|
||||
/// item-cache record's `[rec+0x1c]` and hands back `-1` when the cache has no record for the id
|
||||
/// (wow-re `system/ui/scratch/loot-slot-record.md` §4, a §5 trio round).
|
||||
///
|
||||
/// It is why stock `UIParent.lua` builds `ITEM_QUALITY_COLORS` over **`for i = -1, 6`** (l.66):
|
||||
/// index `-1` exists *for this value*. `LootFrame_Update` indexes the table with the raw return and
|
||||
/// no guard — `color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:82`), dereferenced one line
|
||||
/// later at `color.r` (`:85`) — so a nil here is a Lua runtime error on every loot opened before
|
||||
/// its templates arrive, which is every loot on a cold item cache (decision 1805).
|
||||
const CACHE_MISS_QUALITY: i64 = -1;
|
||||
|
||||
/// The row text on that same miss. The reference composes every loot row's name through `0x5d8b00`,
|
||||
/// which leaves its destination buffer **empty** when the item cache has no record (`0x5d8b25`
|
||||
/// stores the terminator and returns); the binding then pushes that empty string. Four values, one
|
||||
/// of them `""` — never three values, and never a nil.
|
||||
const CACHE_MISS_NAME: &str = "";
|
||||
|
||||
/// The icon on an `ItemDisplayInfo` miss — a row that HAS an item, whose display id the catalog has
|
||||
/// no icon for. The reference appends the literal `INV_Misc_QuestionMark` (`0x847fe4`, `0x4c252b`)
|
||||
/// to the same `StringLookups.dbc` row-3 prefix (`Interface\\Icons`) a hit uses.
|
||||
///
|
||||
/// It is deliberately **not** the answer for a slot with no item: `0x4c2460` returns NULL on each of
|
||||
/// its three guards (`0x4c2470` no window, `0x4c24a6` out of range, `0x4c24b7` itemId == 0) and
|
||||
/// `lua_pushstring 0x6f3890` tail-jumps a NULL to `lua_pushnil`. The question mark needs a live
|
||||
/// itemId to be reached at all.
|
||||
const MISSING_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
|
||||
|
||||
/// The quality a slot with **no item at all** answers — a slot past the end, Lua slot 0, or any slot
|
||||
/// while no loot window is open. `0x4c23a0` returns 0 on each of its three guards, and the binding
|
||||
/// `fild`s that. Distinct from [`CACHE_MISS_QUALITY`]: a **cleared** slot still has a record, whose
|
||||
/// itemId is now 0, and `0x55ba30` short-circuits an entryId of 0 to a NULL record
|
||||
/// (`0x55ba3d`/`0x55ba42`) — a guaranteed cache miss, so it takes the `-1` arm at `0x4c2435`.
|
||||
const NO_SLOT_QUALITY: i64 = 0;
|
||||
|
||||
/// One loot-window row, resolved by the app (decision 0084). Plain data — its 1-based order in the
|
||||
/// window is its position in [`LootState::rows`]; the coin row (when present) is always position 1.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LootRow {
|
||||
/// The row text (`GetLootSlotInfo`'s `item` return): an item's name, or the coin row's formatted
|
||||
/// money amount. `None` while the ask-once item-template query is still in flight (items only —
|
||||
/// the coin row's text is always set); the API reports `nil` and the XML shows a placeholder.
|
||||
/// the coin row's text is always set), and the API reports [`CACHE_MISS_NAME`] — the empty
|
||||
/// string the reference's own name formatter leaves behind on a cache miss — never a nil.
|
||||
pub name: Option<String>,
|
||||
/// Icon texture path (`Interface\Icons\…` — from the wire `display_info_id`, or the coin
|
||||
/// texture). `None` only if the display catalog had no icon for the id.
|
||||
/// texture). `None` only if the display catalog had no icon for the id, which the API reports as
|
||||
/// [`MISSING_ICON`], the reference's own `INV_Misc_QuestionMark`.
|
||||
pub texture: Option<String>,
|
||||
/// The stack size looted (`GetLootSlotInfo`'s `quantity`); the coin row is always `1`.
|
||||
pub quantity: u32,
|
||||
/// Item quality 0..6, for the quality-coloured row text; `None` while the template is in flight
|
||||
/// (the XML falls back to common/white). The coin row carries a fixed quality.
|
||||
/// Item quality 0..6, for the quality-coloured row text; `None` while the template is in flight,
|
||||
/// which the API reports as [`CACHE_MISS_QUALITY`] (`-1`, the reference's cache-miss sentinel and
|
||||
/// a real row of `ITEM_QUALITY_COLORS`) — never a nil, which is what raised in the stock
|
||||
/// `LootFrame_Update` (decision 1805). The coin row carries a fixed quality.
|
||||
pub quality: Option<u32>,
|
||||
/// Whether this is the synthesized coin pile (`LootSlotIsCoin` true, `LootSlotIsItem` false).
|
||||
pub is_coin: bool,
|
||||
@@ -76,9 +115,9 @@ pub struct LootRow {
|
||||
/// `None` for the synthesized coin row (there is no item to link) and while the ask-once
|
||||
/// template answer is in flight — the link embeds the name, so it cannot exist before `name`
|
||||
/// does; the same `Option` shape [`super::char_stats::InvSlotView::link`] carries. Both arms of
|
||||
/// the row click take a nil in stride: `DressUpItemLink` returns on one (its own guard,
|
||||
/// `DressUpFrame.lua:10-16`) and the shift arm goes through `BenillaChatEdit_InsertLink`, which
|
||||
/// drops it — our `EditBox:Insert` binding is typed `String` and would raise.
|
||||
/// the STOCK row click take a nil in stride: `DressUpItemLink` returns on one (its own guard,
|
||||
/// `DressUpFrame.lua:10-16`), and `ChatFrameEditBox:Insert` takes an `Option<String>`
|
||||
/// ([`super::editbox`]) so the other drops it silently.
|
||||
pub link: Option<String>,
|
||||
/// The wire's `randomPropertyId` — the drop's **random-suffix roll**, the id the tooltip
|
||||
/// resolves against [`super::Model::random_properties`] for its enchant lines (decision 1547).
|
||||
@@ -174,36 +213,60 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetLootSlotInfo(slot) → texture, item, quantity, quality (the Era flat-tuple shape,
|
||||
// `LootFrame.lua:81`). `slot` is 1-based; out of range → nil, and so is a cleared (already
|
||||
// looted) slot. `item`/`quality` are nil while the item-template query is in flight; `texture`
|
||||
// rides the wire display id, so it's there at once.
|
||||
// GetLootSlotInfo(slot) → texture, item, quantity, quality — the reference's four, in its order
|
||||
// (`0x4c2c60`, `mov eax,4` at `0x4c2d07`; `LootFrame.lua:81`). `slot` is 1-based.
|
||||
//
|
||||
// **It answers four values on every leg — never three, never a bare nil** (decision 1805).
|
||||
// A slot with no item at all (past the end, slot 0, no window) is `nil, "", 0, 0`; a CLEARED
|
||||
// slot is `nil, "", 0, -1`, because its record survives with a zeroed itemId and that is a
|
||||
// guaranteed item-cache miss.
|
||||
//
|
||||
// A LIVE row is the same story one level down: each producer has a sentinel for the case where
|
||||
// the value it wants is not resolved yet — [`CACHE_MISS_QUALITY`] (`-1`) and [`CACHE_MISS_NAME`]
|
||||
// (`""`) while the item template is in flight, [`MISSING_ICON`] when the display catalog has no
|
||||
// icon for the wire display id. The quality is the load-bearing one: the reference's
|
||||
// `ITEM_QUALITY_COLORS` has a `-1` row precisely so this value can index it.
|
||||
//
|
||||
// The in-flight row is nonetheless one the REFERENCE never paints, because it does not open the
|
||||
// window until every template has landed ([`crate::script`] has no say in that; the app's
|
||||
// `ui_loot` holds `LOOT_OPENED` back). These sentinels are the floor under that, not the plan.
|
||||
g.set(
|
||||
"GetLootSlotInfo",
|
||||
lua.create_function(|lua, slot: usize| {
|
||||
let row = {
|
||||
// `Some(Some(row))` is a live row; `Some(None)` is a CLEARED one (the slot survives with
|
||||
// its record zeroed); `None` is no such slot at all.
|
||||
let slot_state = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.loot
|
||||
.as_ref()
|
||||
.and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
|
||||
.and_then(|r| r.clone())
|
||||
.cloned()
|
||||
};
|
||||
let Some(row) = row else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
};
|
||||
let texture = match &row.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
let item = match &row.name {
|
||||
Some(n) => Value::String(lua.create_string(n)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
let quality = match row.quality {
|
||||
Some(q) => Value::Integer(i64::from(q)),
|
||||
None => Value::Nil,
|
||||
let Some(Some(row)) = slot_state else {
|
||||
// No row — and the reference still pushes four (`0x4c2d07 mov eax,4` is
|
||||
// unconditional; there is no short-return path). A cleared slot's record has a
|
||||
// zeroed itemId, which `0x55ba30` short-circuits to a NULL cache record, so it takes
|
||||
// the cache-miss quality where a genuinely absent slot takes the guard's zero.
|
||||
let cleared = slot_state.is_some();
|
||||
return Ok(MultiValue::from_vec(vec![
|
||||
Value::Nil,
|
||||
Value::String(lua.create_string(CACHE_MISS_NAME)?),
|
||||
Value::Integer(0),
|
||||
Value::Integer(if cleared {
|
||||
CACHE_MISS_QUALITY
|
||||
} else {
|
||||
NO_SLOT_QUALITY
|
||||
}),
|
||||
// Benilla's trailing item id: there is no item here.
|
||||
Value::Integer(0),
|
||||
]));
|
||||
};
|
||||
let texture =
|
||||
Value::String(lua.create_string(row.texture.as_deref().unwrap_or(MISSING_ICON))?);
|
||||
let item =
|
||||
Value::String(lua.create_string(row.name.as_deref().unwrap_or(CACHE_MISS_NAME))?);
|
||||
let quality = Value::Integer(row.quality.map_or(CACHE_MISS_QUALITY, i64::from));
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
texture,
|
||||
item,
|
||||
@@ -219,9 +282,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// like GetLootSlotInfo beside it; nil out of range, nil for the coin row, and nil while the
|
||||
// item-template query is in flight (the link embeds the name). The reference's row click reads
|
||||
// it for BOTH modifier arms — `DressUpItemLink(GetLootSlotLink(this.slot))` (`LootFrame.lua:149`)
|
||||
// and `ChatFrameEditBox:Insert(GetLootSlotLink(this.slot))` (`:152`); ours routes the second
|
||||
// through `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer.
|
||||
// Decision 1059.
|
||||
// and `ChatFrameEditBox:Insert(GetLootSlotLink(this.slot))` (`:152`). Since 1751 put the stock
|
||||
// file on the chain both of those are the live call sites — our own `BenillaChatEdit_InsertLink`
|
||||
// detour is not on this path any more — and both survive the nil on their own. Decision 1059.
|
||||
g.set(
|
||||
"GetLootSlotLink",
|
||||
lua.create_function(|lua, slot: usize| {
|
||||
@@ -471,13 +534,29 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), qty), ("Wool Cloth", 3));
|
||||
|
||||
// Row 3: in flight — item name + quality nil, texture + quantity still present.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local t, i, q, ql = GetLootSlotInfo(3)\n\
|
||||
return i == nil and ql == nil and t ~= nil and q == 1",
|
||||
)
|
||||
.unwrap());
|
||||
// Row 3: in flight — the reference's cache-miss SENTINELS, not nils (decision 1805). The
|
||||
// quality is the load-bearing one: stock `LootFrame_Update` does
|
||||
// `ITEM_QUALITY_COLORS[quality].r` with no guard, and `-1` is a real row of that table
|
||||
// (`UIParent.lua` builds it `for i = -1, 6`) while a nil is a runtime error.
|
||||
let (texture, item, quantity, quality) = s
|
||||
.eval::<(String, String, i64, i64)>("return GetLootSlotInfo(3)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(item.as_str(), quantity, quality),
|
||||
("", 1, -1),
|
||||
"an in-flight row answers \"\" / -1, never nil"
|
||||
);
|
||||
assert_eq!(texture, "Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
// …and the same row with NO display-info icon either still answers a texture path.
|
||||
let mut iconless = loot();
|
||||
iconless.rows[2].as_mut().unwrap().texture = None;
|
||||
s.set_loot(Some(iconless));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return (GetLootSlotInfo(3))").unwrap(),
|
||||
"Interface\\Icons\\INV_Misc_QuestionMark",
|
||||
"a display-info miss answers the reference's question mark, not nil"
|
||||
);
|
||||
s.set_loot(Some(loot()));
|
||||
|
||||
// GetLootSlotLink: the resolved item's link, nil for the coin row and for the in-flight one
|
||||
// (both arms of the reference's row click hand this straight on — decision 1059).
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
//!
|
||||
//! `rollID` is **client-internal** — it never reaches the wire (`CMSG_LOOT_ROLL` addresses a roll by
|
||||
//! `(lootedTarget, itemSlot)` instead), so the app allocates its own monotonic id per open roll.
|
||||
//! An unknown `rollID` answers `nil` / `0`, exactly as an out-of-range loot slot does.
|
||||
//! An unknown `rollID` answers the reference's own miss values — `nil` for the strings, `0` for
|
||||
//! `GetLootRollTimeLeft`, and `GetLootRollItemInfo`'s five-value tail (`0x4c31a3`).
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
@@ -34,6 +35,19 @@ use super::Model;
|
||||
/// `rollType` 0 — the one vote the bind-on-pickup gate never intercepts (passing binds nothing).
|
||||
const PASS: u8 = 0;
|
||||
|
||||
/// What `GetLootRollItemInfo` answers for `count` and `quality` when the item-template cache has no
|
||||
/// record — the reference's own shared miss tail `nil, nil, 1.0, 1.0, nil` at `0x4c31a3` (wow-re
|
||||
/// `system/object-layer/scratch/lootroll-chat-and-lifecycle.md` §5). `count` is a literal `1.0` on
|
||||
/// the hit path too (`0x4c3160` — a group roll is always one stack), so only the quality is really a
|
||||
/// sentinel, and it is **`1` (Common), not `GetLootSlotInfo`'s `-1`**: each accessor carries its own,
|
||||
/// and copying one to the other would be a guess.
|
||||
///
|
||||
/// It matters for the same reason 1805's did. Stock `GroupLootFrame_OnShow` reads this value straight
|
||||
/// into `ITEM_QUALITY_COLORS[quality]` and dereferences it on the next line (`LootFrame.lua:275-276`)
|
||||
/// with no guard; our `GroupLootFrame.xml` carries the `or ITEM_QUALITY_COLORS[1]` that the stock
|
||||
/// file does not, so the nil this used to answer is a raise waiting for that file's migration.
|
||||
const CACHE_MISS_QUALITY: i64 = 1;
|
||||
|
||||
/// One open group loot roll, resolved by the app (decision 0591). Plain data — the app rebuilds the
|
||||
/// whole list each frame, so `time_left_ms` is simply re-derived rather than ticked here.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -48,7 +62,9 @@ pub struct LootRollEntry {
|
||||
pub texture: Option<String>,
|
||||
/// The stack size being rolled for (`GetLootRollItemInfo`'s `count`).
|
||||
pub quantity: u32,
|
||||
/// Item quality 0..6, for the quality-coloured name; `None` while the template is in flight.
|
||||
/// Item quality 0..6, for the quality-coloured name; `None` while the template is in flight,
|
||||
/// which the API reports as [`CACHE_MISS_QUALITY`] (`1`, Common) rather than a nil — the
|
||||
/// reference's own miss tail, and a real row of `ITEM_QUALITY_COLORS`.
|
||||
pub quality: Option<u32>,
|
||||
/// Whether the item binds when picked up — the gold-backdrop swap in `GroupLootFrame_OnShow`.
|
||||
/// `false` while the template is in flight (the plain backdrop is the safe default).
|
||||
@@ -112,20 +128,31 @@ fn find(model: &Model, roll_id: u32) -> Option<&LootRollEntry> {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetLootRollItemInfo(rollID) → texture, name, count, quality, bindOnPickUp, itemID.
|
||||
// An unknown id answers nil (the frame is mid-teardown, or an addon asked for a stale roll).
|
||||
// GetLootRollItemInfo(rollID) → texture, name, count, quality, bindOnPickUp, itemID (`0x4c3050`;
|
||||
// the sixth is ours). An unknown id — the frame is mid-teardown, or an addon asked for a stale
|
||||
// roll — is the reference's own miss leg, and it does not return *nothing*: `0x4c31a3` pushes
|
||||
// `nil, nil, 1.0, 1.0, nil`. So does a roll whose item template has not landed, because the
|
||||
// reference reads every one of these off the item-template cache record and takes the same tail
|
||||
// when it is absent. See [`CACHE_MISS_QUALITY`] — the quality is the one a stock caller indexes.
|
||||
g.set(
|
||||
"GetLootRollItemInfo",
|
||||
lua.create_function(|lua, roll_id: u32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(r) = find(&model, roll_id) else {
|
||||
return Ok(mlua::MultiValue::new());
|
||||
return lua.pack_multi((
|
||||
mlua::Value::Nil,
|
||||
mlua::Value::Nil,
|
||||
1,
|
||||
CACHE_MISS_QUALITY,
|
||||
mlua::Value::Nil,
|
||||
mlua::Value::Nil,
|
||||
));
|
||||
};
|
||||
let vals = (
|
||||
r.texture.clone(),
|
||||
r.name.clone(),
|
||||
r.quantity,
|
||||
r.quality,
|
||||
r.quality.map_or(CACHE_MISS_QUALITY, i64::from),
|
||||
r.bind_on_pickup,
|
||||
r.item_id,
|
||||
);
|
||||
@@ -276,11 +303,13 @@ mod tests {
|
||||
17182
|
||||
);
|
||||
|
||||
// The in-flight roll: name/texture/quality nil, count + BoP default still readable.
|
||||
// The in-flight roll: name/texture nil as the reference's miss tail has them, but the
|
||||
// QUALITY is its sentinel `1` and not a nil — stock `GroupLootFrame_OnShow` indexes it into
|
||||
// `ITEM_QUALITY_COLORS` and dereferences the result with no guard (`LootFrame.lua:275-276`).
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local t, n, c, q, b = GetLootRollItemInfo(8)\n\
|
||||
return t == nil and n == nil and q == nil and c == 2 and b == false",
|
||||
return t == nil and n == nil and q == 1 and c == 2 and b == false",
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
@@ -294,10 +323,22 @@ mod tests {
|
||||
.eval::<bool>("return GetLootRollItemLink(8) == nil")
|
||||
.unwrap());
|
||||
|
||||
// An unknown id → nil / 0, never an error.
|
||||
// An unknown id → the reference's five-value miss tail (`0x4c31a3`), never an error and
|
||||
// never *nothing*: nil, nil, 1, 1, nil — so a stock caller that unpacks all five and indexes
|
||||
// the quality still finds a colour.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetLootRollItemInfo(99) == nil")
|
||||
.unwrap());
|
||||
let (count, quality) = s
|
||||
.eval::<(i64, i64)>("local _, _, c, q = GetLootRollItemInfo(99)\nreturn c, q")
|
||||
.unwrap();
|
||||
assert_eq!((count, quality), (1, 1), "the miss tail is 1, 1");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetLootRollItemInfo(99))")
|
||||
.unwrap(),
|
||||
6,
|
||||
"five reference returns plus benilla's trailing item id"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetLootRollItemLink(99) == nil")
|
||||
.unwrap());
|
||||
|
||||
Reference in New Issue
Block a user