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:
Sam
2026-09-02 17:02:34 +07:00
co-authored by Claude Fable 5.1
parent 24fbb706f9
commit cac6e88145
27 changed files with 1611 additions and 434 deletions
+10 -6
View File
@@ -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"},
+5 -2
View File
@@ -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;
+10 -9
View File
@@ -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 —
+21 -18
View File
@@ -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
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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()",
)
+35 -18
View File
@@ -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
View File
@@ -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"), "");
+17 -12
View File
@@ -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,
}
}
}
+25 -12
View File
@@ -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,
}
}
}
+2 -2
View File
@@ -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 {
+191 -31
View File
@@ -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);
+14 -8
View File
@@ -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:?}"
);
}
+211 -96
View File
@@ -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(),
+40 -21
View File
@@ -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]));
+22 -12
View File
@@ -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");
}
}
+116 -37
View File
@@ -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).
+50 -9
View File
@@ -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());