the leader crown updates, the trail darkens, and four dead options work

- the party-leader crown on your target's portrait only ever appeared or vanished when you re-targeted them: `PLAYER_FLAGS_CHANGED` was one of eighteen events the migrated stock windows register that nothing here produced, and the census row naming it said "TargetFrame.lua — the AFK/DND badge", which sent the first look at the wrong window — 1.12 draws no AFK/DND badge on any unit frame and build 5875 has no `UnitIsAFK`/`UnitIsDND` at all (ours are a deliberate invention, 2043; the `<AFK>`/`<DND>` the era shows is the chat line's `arg6`), and the event's sole FrameXML consumer is `TargetFrame.lua:88-95` re-running `UnitIsPartyLeader("target")`, whose descriptor leg is bit `0x1` of this very dword; the handler `0x5ee990` XOR-diffs old against new at `0x5ee9b8` and fires 407 at `0x5eea3d` with no bit test in between and ABOVE the local-player GUID gate at `0x5eea93`, once per unit token naming that GUID through `0x515e50`'s fan-out (`arg1` = the token, no `arg2`, and a zero token count skips the fan-out so a stranger nobody targets signals nothing — free here, since `fire_transitions` only ever runs per token); so the trigger is the RAW dword, `UnitState::player_flags`, the `flags`/`UNIT_FLAGS` pattern one field over, because firing off the two bits the struct decodes would under-announce AFK, DND, GM, PvP-desired, helm, cloak and both play-time bits, and `prev.is_some()` keeps a unit's first snapshot silent as its CREATE runs no notify pass; reading the whole handler found an over-fire that was inherited, not invented: benilla fired `PLAYER_UPDATE_RESTING` on ANY flags delta because wow-re's rested-XP note said in as many words that `0x5eeaf2` fires "on every flags change, not only the resting bit", and the bytes say otherwise — `0x5eead0` `f6 45 fc 20 / 74 26` is `test byte [ebp-4],0x20 ; je 0x5eeafc` and the fire sits inside that arm — so every helm toggle, leadership pass and AFK flip had been announcing a resting change to the whole UI; narrowed to the resting bit's own edge, and the same handler's `0x5eeb65 test ah,0x30` arm becomes `PLAYTIME_CHANGED`, whose consumer `PlayerFrame_UpdatePlaytime` already had all three verbs it reads; the claim is corrected in wow-re by a four-derivation round that confirmed all three arms plus the CREATE question; three tests pin it — the producer with a control on `0x400`, a bit no field decodes; the over-fire regression on the real feed, where a helm bit moving fires neither self event; and the crown itself on the stock `TargetFrame.lua`, leadership passing to a held target lighting it with no re-target and the same event for `arg1 = "player"` not — a fixture that first failed on its opening assertion because `UnitIsPartyLeader`'s second leg is a raw GUID compare with no zero guard, so a default `guid: 0` equalled the zeroed leader of an empty group (verified 1.12: it is why `UnitIsPartyLeader(nil)` answers 1 while solo); the unproduced-events census goes 18 → 16 per window, 31 → 29 whole (2078)
- the weapon swing trail darkens with the scene instead of burning at its authored colour: 2076 shipped it unlit and said why — the draw callback turns lighting ON (EGxRs `0x0e = 1` at `0x6c6847`) over vertex format 7, position + packed colour with no normal, and the state bit alone does not say what fixed-function produces from that; the bytes now do: `0x5a1e30` sets DIFFUSE- and AMBIENTMATERIALSOURCE both to `D3DMCS_COLOR1` off the format's diffuse bit, so the vertex colour IS the material; the normal is explicitly disabled rather than stale (`0x592a60` replaces the format mask instead of OR-ing it, `0x59c100` issues `glDisableClientState(GL_NORMAL_ARRAY)` when the bit is clear, and D3D's format-7 descriptor carries −1 in the normal column), so there is no N·L and the diffuse half contributes nothing; and the global `D3DRS_AMBIENT` (EGxRs id 6) has zero writers image-wide and defaults to 0, an earned zero — leaving `out = (Σ enabled lights' ambient) × authoredColour + inherited emissive`, which is why this does not take the lit emitters' `clamp(ambient + diffuse·max(N·L, 0))` term, a sun the reference does not have; `weapon_trail.rs` multiplies the trail's RGB by `WowLighting.ambient`, folded on the CPU into the vertex colours because a per-draw constant rides the vertex stream on this lane, drawing as `EffectLighting::None` and at the authored colour until the first lighting resolve; two named approximations of the class the fishing line already carries — the scene ambient rather than a per-model light collector, so an interior trail takes the exterior ambient, and no emissive, since `EMISSIVEMATERIALSOURCE` is set nowhere and reproducing it would mean inventing a constant; the other half of 2076's open list was a deviation benilla does not have: it claimed the combat-over-combat fast path (`0x5fe43c` returning at `0x5fe48b`, before the trail latch's only read at `0x5fe48e`) was unmodelled and cost trails starting one play early — both wrong, because `0x60d835` arms a one-slot field with a plain `mov` so a second proc OVERWRITES the first and firing on every request would draw superseded arms with superseded colours on 23 of the 34 type-8 kits, and because the driver's request loop already fast-paths it (0406, `select::is_combat_anim` = `0x5fcc10`'s byte-decoded set) and `continue`s without playing, so the edge never rises; a negative asserted about our own code from having written it rather than looked, shipped to wow-re where it drove a build-it verdict for something already built — now pinned by a test that names both mechanisms; and `benilla-extract charprocs` prints `CharParamOne` beside every trail row: wow-re's note justified "not read by this arm" with "every shipped type-8 row carries 20.0 there", and over the 34 kits the column takes six values (22 at 20, six at 15, two at 25, two at 0, one at 17, one at 22 — Sinister Strike's kit 399 is a 15); nothing here reads it either way, but a census that hides the one column a note asserts about cannot catch the note being wrong, and wow-re's re-measure agreed value for value and found the column live at four sites in `0x60d7c0` for other proc types (2079)
- four options that ticked and did nothing — Show Tutorials, Tip of the Day, Profanity Filter, Disable Spam Filter — get their backing, and two of the reasons they were dead were wrong: none of the four was a row in our window at all; they sat in the transcribed `UIOptionsFrameCheckButtons` declaration table beside thirteen more whose features benilla lacks, and these four are exactly the ones where the feature existed and only the setting was missing; `SHOW_TUTORIALS` is a bare `{ index = 28 }`, which an earlier census read as "an index-only entry with no store at all" — wrong, the store is the three special-case arms `UIOptionsFrame_Load` (l.239), `_Save` (l.318) and `_SetDefaults` (l.645) keep for the key, `TutorialsEnabled()` reading the acknowledged bank and `ResetTutorials()`/`ClearTutorials()` writing it, verbs benilla has had since 1976, so the row was one `func` pair away, and the reference's `~=` guard comes with it because without it a Defaults click on a page where tutorials are already on would `ResetTutorials()` and re-arm every popup the player has dismissed; that census moves out of prose into a test checked both directions, the posture the CVar defaults already hold — a name the reference's table carries that nothing registers and no listed blocker fails, and a listed name that has since been registered fails too, twelve rows listed today with the feature each waits on; the two chat filters are PCRE over shipped DBCs, not word lists: `ChatProfanity.dbc` (2289 rows off `patch.MPQ` — `dbc.MPQ`'s copy is 1512) feeds the masker `0x4a1a60` and `SpamMessages.dbc` (28 gold-seller URL patterns; `dbc.MPQ` has none) feeds the predicate `0x4a1ca0`; Blizzard PATCHED PCRE's escape table (`.rdata 0x812330`) so `\<`/`\>` alias onto `\b`, and an unpatched engine makes them literals and silently kills the whole ASCII half of the profanity list while looking correct; CASELESS folds ASCII only, so pattern and subject are both ASCII-lowercased and matched case-sensitively rather than through `(?i)`, which would match `É` where the reference matches nothing; the masker is in place and length-preserving, one token per byte cycling `!@#$%^&*` from a process-global index never reset for the session, patterns in list order not text order — wow-re's oracle transcript reproduced against the real list, "you are a twat and a faggot" → "you are a &*!@ and a !@#$%^", the later word taking the earlier characters because its row (507) precedes the other's (530); two corrections to what this tree believed: `spamFilter` is armed for every chat type EXCEPT `CHAT_MSG_FILTERED` (`0x49aad2`'s `jne` skips the disarm, which the older note read backwards, so our comments claimed the filter bit only on the one type it is exempt from), and `profanityFilter` gates INSIDE the masker at `0x4a1a66`, covering all thirteen call sites rather than the guild MOTD alone; `CHAT_MSG_FILTERED` (`0x5B`) comes with them — it was on the "never `SMSG_MESSAGECHAT` wire" list and is the server's "your message was filtered" notice `ChatFrame.lua` renders at l.1405, dropped with a warning before and delivered now; the chokepoint runs spam, then mask, then fire, in the reference's order, since a dropped line must not consume mask indices; named rather than faked: the other twelve masker sites (mail, guild, item text, the send path) and their `dl = 1` memo, and the whisper leg's `CMSG_CHAT_FILTERED` report whose body is a wow-re DEFERRED with no vmangos handler; each row sits in 1.12's own seat — Profanity Filter closes the Basic Options Display column, Show Tutorials and Show Loading Screen Tips open the Help box, Disable Spam Filter is the last link of the Advanced chat chain and value-inverted, checked ⇔ `spamFilter == "0"` (2077)
- the loading screen shows the tip of the day: `GameTips.dbc` is 74 records in the 5875 enGB chain, off `patch.MPQ`, ids unsorted (the file opens on 396 and closes on 412), so the index the client persists is a record index in load order, never an id; `gameTip` holds the NEXT index, not the one on screen — the client reads it, shows that row and stores `read + 1`, so on-disk values run 1..=74 and never 0, and the reference's own `Config.wtf` reading `SET gameTip "34"` while showing row 33 is exactly what an off-by-one anchors on; the wrap is a clamp, not a modulo (`if (i < 0 || i >= count) i = 0` at `0x46b682`), the walk is sequential, and both guards — `showGameTips` off and the roster's level-zero flag — jump past the `CVar::Set` as well as the draw, so turning tips off freezes the cursor rather than burning through the table invisibly; it draws only on the glue→world transition, because the setter `0x406630` has exactly one caller inside `EnterWorld` and neither `SMSG_TRANSFER_PENDING` arm reaches it, so the pick hangs off the loading screen's world-entry raise and a portal or worldport screen carries no tip; placement is the reference's: `s = 515/(a·1024)` with `a` = aspect × 0.75 (not W/H, which inflates the wrap width by a third at 16:9, and not the FrameXML-length idiom, which is off by 1.25 at 4:3), the block at `(0.5 − s·0.5, 0.1)`, left-justified, body `0xd7c8c8c8` with an opaque black shadow at `(+0.001, −0.001)`, laid out once per raise between the backdrop and the progress bar; the one term deliberately not applied is `yoff = (1 − a)·0.5`, which wow-re left unreconciled (`0x406a60` already applies the letterbox as a viewport and `yoff` is the same term added again inside it, a possible double count on a display narrower than 4:3) — benilla's loading screen is a 4:3 content box by construction and at 4:3 the reference's own `yoff` is zero, so the tip sits at the reference's layout on the one aspect where the ambiguity cannot bite; the `|cffffd100Tip:|r ` prefix and the trailing CR-LF are in the data and the escapes are interpreted, so the gold "Tip:" is markup and the line is built through the markup tokenizer; the cursor persists through the VM's own CVar table (`write_host_cvar`), since the config file is composed from that table and a host write stopping at the knob would reset it every launch — a live run reads `74 game tips` → `tip 0 of 74` → `tip 1 of 74` with `gameTip = "2"` left in the config, and an earlier live run is what caught a `GameTipSetting` the test harness initialised and the real app did not, a panic no unit test could see (2077)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:11:11 +07:00
co-authored by Claude Fable 5.1
parent cf3189e422
commit ecbdf2f716
26 changed files with 2514 additions and 69 deletions
@@ -798,6 +798,10 @@ fn interface_harness() -> UiScript {
"Interface\\FrameXML\\QuestFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
// The Show Tutorials row's setter is the reference's own Save arm, which reaches
// `TutorialFrameCheckButton` and `TutorialFrame_HideAllAlerts` UNGUARDED (2077) — so
// the page's harness loads the window that declares them, as the client does.
"Interface\\FrameXML\\TutorialFrame.xml",
],
);
harness_on(s)
@@ -2299,13 +2303,13 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
// 1136, Buff Durations 1139, the target-of-target pair 1576), the Action Bars page's 2 (the
// lock 1136, Always Show
// ActionBars 1500) and the Chat page's 1 (Remove Chat Hover Delay, 1589) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// ActionBars 1500) and the Chat page's 1 (Remove Chat Hover Delay, 1589) + 7 API rows (the Interface page's Show Cloak / Show Helm, 1472 and
// Show Tutorials, 2077; the Action Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
// 55 of the 58 are 1.12's own; the other three are Render Scale (1639), Display Mode
// 59 of the 62 are 1.12's own; the other three are Render Scale (1639), Display Mode
// (1650) and Enable Sound in Background (1847), whose descriptions are benilla's and whose
// carve-out is above.
// The 28th CVar row is Block Trades (1764), on the Controls page — its key
@@ -2313,7 +2317,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto; the 30th is
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
// page the reference never made settable.
assert_eq!(checked, 58, "every tipped row carries a live key");
// The 7th API row is Show Tutorials (2077) — the Interface page's Help box, whose store is
// `TutorialsEnabled()` / `ResetTutorials()` / `ClearTutorials()` rather than a CVar, and whose
// key OPTION_TOOLTIP_SHOW_TUTORIALS is 1.12's own.
// The 30th and 31st CVar rows are the two text filters (2077): Profanity Filter on the
// Interface page and Disable Spam Filter on the Chat page, both keys 1.12's own.
assert_eq!(checked, 62, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2423,7 +2432,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// rather than 1.12 GlobalStrings — see the guard above.
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
// (1815), the Audio page's fourth checkbox and 1.12's own.
assert_eq!(raised, 58, "every row but Auto Loot raises a description");
// …and Show Tutorials (2077), the Interface page's seventh API row.
// …and the two text-filter rows (2077).
assert_eq!(raised, 62, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -3034,6 +3045,90 @@ fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Show Tutorials** (decision 2077) — the fourth `func` row, and the one 1.12's own table hides:
/// its `UIOptionsFrameCheckButtons` entry is a bare `{ index = 28 }`, so a reading of the table
/// alone (decision 1140's census) files it as unbound. The store is the three special-case arms —
/// `TutorialsEnabled()` reads it, `ResetTutorials()`/`ClearTutorials()` write it — which is what
/// this asserts: the row reads the acknowledged bank, ticking it OFF clears, ticking it back ON
/// resets, and the reference's `~=` guard keeps a no-op click from re-arming every dismissed
/// tutorial.
#[test]
fn show_tutorials_reads_the_bank_and_writes_through_clear_and_reset() {
let mut s = interface_harness();
// A bank with unacknowledged bits: tutorials are enabled.
s.set_tutorial_bank(Some(vec![0x00; 32]));
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the row reads TutorialsEnabled(), not a stored value"
);
let _ = s.take_cvar_changes();
let _ = s.take_tutorial_clears();
let _ = s.take_tutorial_resets();
// Ticking it off is `ClearTutorials()` — every bit acknowledged.
s.run("OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:Click()")
.unwrap();
assert_eq!(s.take_tutorial_clears(), 1, "off clears the bank");
assert_eq!(s.take_tutorial_resets(), 0);
assert!(
s.take_cvar_changes().is_empty(),
"there is no tutorial CVar in 1.12 — the row must not invent one"
);
// The app answers by pushing the cleared bank back; the row now reads off.
s.set_tutorial_bank(Some(vec![0xFF; 32]));
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the revisit re-asks the getter"
);
// Ticking it back on is `ResetTutorials()`.
s.run("OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:Click()")
.unwrap();
assert_eq!(s.take_tutorial_resets(), 1, "on resets the bank");
assert_eq!(s.take_tutorial_clears(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's `~=` guard, which is the whole reason its Save arm is not a bare pair of calls:
/// setting the row to the value it already holds must send NOTHING. Without it, a Defaults click on
/// a page where tutorials are already on would `ResetTutorials()` and re-arm every popup the player
/// has dismissed — an option that silently undoes hours of play.
#[test]
fn a_no_op_write_does_not_re_arm_the_tutorials() {
let mut s = interface_harness();
s.set_tutorial_bank(Some(vec![0x00; 32])); // enabled
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
let _ = s.take_tutorial_clears();
let _ = s.take_tutorial_resets();
// Defaults writes "1" over a row that already reads "1".
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
(s.take_tutorial_resets(), s.take_tutorial_clears()),
(0, 0),
"the guard held: no bank write for a value that had not moved"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on an API row: both boxes go back to shown, and the flip is sent **only** for the one
/// that was actually off — the setter is a *set* over a wire verb that is a blind *toggle*, so a
/// no-op default must not queue a packet that would turn the preference on its head.
@@ -3329,6 +3424,78 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
/// click queues the flag the host drains onto `BubbleConfig`, and the two are independent (the
/// client gates party lines on their own CVar, which is why party bubbles survive turning
/// say/yell bubbles off).
/// **Disable Spam Filter** (decision 2077) — the reference's own *inverted* row, and the one shape
/// on this page that would read backwards if it were wired like its neighbours.
///
/// `spamFilter` registers `"1"` (the filter ON), and the label says *Disable*: 1.12 checks the box
/// when `GetCVar == "0"` (`UIOptionsFrame.lua` l.253) and flips the value before writing it
/// (l.337). So a fresh client shows the box **unchecked**, and ticking it turns the filter off.
#[test]
fn the_disable_spam_filter_row_is_inverted() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
assert_eq!(
s.cvar("spamFilter").as_deref(),
Some("1"),
"the registered default is the filter ON"
);
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyChatRowSpamFilterCheck:GetChecked() and true or false"
)
.unwrap(),
"a filter that is ON shows *Disable Spam Filter* unchecked"
);
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("spamFilter".to_string(), "0".to_string())],
"ticking *Disable* writes the CVar OFF"
);
assert_eq!(s.cvar("spamFilter").as_deref(), Some("0"));
// And back: the row round-trips rather than latching.
s.run("OptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(s.cvar("spamFilter").as_deref(), Some("1"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Profanity Filter** (decision 2077) — an ordinary CVar row, asserted because its *seat* is the
/// surprising part: 1.12 files it under Basic Options' **Display** box (CheckButton5 anchors under
/// CheckButton66), not with the chat settings its name suggests, so it closes the Interface page's
/// column-C chain rather than joining the Chat page.
#[test]
fn the_profanity_filter_row_sits_on_the_interface_page_and_writes_its_cvar() {
let mut s = interface_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowProfanityFilterCheck:GetChecked() \
and true or false"
)
.unwrap(),
"registered \"1\" — a stock client boots with profanity masking on"
);
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyInterfaceRowProfanityFilterCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("profanityFilter".to_string(), "0".to_string())],
"not inverted — the label and the CVar agree"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn the_chat_page_toggles_the_chat_bubble_cvars() {
// No host override: since 1804 the registered pair IS bubbles-on / party-off, so the page's
@@ -4077,3 +4244,119 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The reference table's CVar census (decision 2077) ────────────────────────────────────────────
//
// `UIOptionsFrameCheckButtons` in `OptionsFrame.xml` is a transcription of the reference's own
// table (`UIOptionsFrame.lua` l.4-84), and every entry names its store. Twenty-eight of them name a
// **CVar** — and a name in that table is a claim: `SetCVar(value.cvar, …)` against a CVar this
// client never registered writes nothing and reads back nil, so the entry is a setting benilla
// appears to offer and does not.
//
// Decision 1140 §4 wrote that census as prose in a decision record, and prose in a record is a
// snapshot: by the time anyone looked again it said SHOW_TUTORIALS had "no store at all" (it has
// three special-case arms, 2077) and had been amended once already for the multibar rows. So the
// census lives here instead, where it cannot go stale — the same posture as `cvars::Reference`,
// and checked in **both directions**: an unregistered name missing from this list fails, and a
// listed name that has since been registered fails too.
//
// A row on this list is not a defect. It is a 1.12 setting whose *feature* benilla does not have,
// and the reason is the row's whole point — "no key without a reader" (1134 §4) is why the CVar is
// absent, and this is where that decision is written down per setting.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
("autointeract", "click-to-move"),
("assistAttack", "assist-attack"),
("autoClearAFK", "no auto-clear of the AFK flag"),
("UnitNamePlayerGuild", "guild names over player nameplates"),
(
"UnitNamePlayerPVPTitle",
"PvP titles over player nameplates",
),
("cameraTerrainTilt", "camera terrain tilt"),
("cameraBobbing", "camera head bob"),
("cameraWaterCollision", "camera water collision"),
("cameraPivot", "the smart-pivot camera"),
(
"CombatDamage",
"the floating-combat-text master gate is `combat_text::law::COMBAT_DAMAGE`, a const bool \
— the same shape ChatBubbles had before 1139: a faithful gate with no way to reach it",
),
("CombatLogPeriodicSpells", "a combat log"),
(
"PetMeleeDamage",
"`combat_text::law::PET_MELEE_DAMAGE`, a const bool — see CombatDamage",
),
];
/// **Every CVar the reference's own options table names is registered here, or listed above with
/// the feature it waits on** (decision 2077) — the check that turns 1140 §4's prose census into
/// something that cannot rot, in both directions.
///
/// This is the instrument for the whole class: a table entry that names a CVar benilla does not
/// register is a row that would tick and do nothing, and until now the only thing standing between
/// us and shipping one was somebody re-reading a decision record from months ago.
#[test]
fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker() {
let s = harness();
// The table as the file declares it: `name` (the display key) -> its `cvar`, if it has one.
let named: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for key, v in pairs(UIOptionsFrameCheckButtons) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
table.sort(out) \
return out",
)
.expect("read UIOptionsFrameCheckButtons");
assert!(
named.len() >= 28,
"the transcription lost rows: only {} cvar entries",
named.len()
);
let registered: std::collections::HashSet<String> = crate::cvars::registered_pairs()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let listed: std::collections::HashSet<String> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let mut unlisted: Vec<&str> = Vec::new();
for cvar in &named {
let key = cvar.to_ascii_lowercase();
if !registered.contains(&key) && !listed.contains(&key) {
unlisted.push(cvar);
}
}
assert!(
unlisted.is_empty(),
"these rows name a CVar nothing registers, and nothing says why: {unlisted:?} — either \
build the backing or add the row to UNBACKED_REFERENCE_CVARS with its blocker"
);
// The other direction: a listed name that IS registered now means the feature landed and the
// row is stale — exactly the drift `cvars::Reference` guards against on its own column.
let stale: Vec<&str> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| registered.contains(&n.to_ascii_lowercase()))
.collect();
assert!(
stale.is_empty(),
"these are registered now — drop them from UNBACKED_REFERENCE_CVARS and give them a row: \
{stale:?}"
);
// And a listed name the table never mentions is a row describing nothing.
let phantom: Vec<&str> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| !named.iter().any(|c| c.eq_ignore_ascii_case(n)))
.collect();
assert!(
phantom.is_empty(),
"UNBACKED_REFERENCE_CVARS names CVars the reference's table does not: {phantom:?}"
);
}
@@ -2345,11 +2345,6 @@ mod tests {
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
(
"PLAYER_FLAGS_CHANGED",
"TargetFrame.lua — the AFK/DND badge",
),
("PLAYTIME_CHANGED", "PlayerFrame.lua"),
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
@@ -2176,3 +2176,84 @@ fn the_unit_frames_publish_every_name_the_reference_declares() {
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The reported symptom, on the stock file that has it: the target's leader crown went stale.**
///
/// `PLAYER_FLAGS_CHANGED` was one of the events a migrated window registered and nothing here
/// produced (decision 2078). `TargetFrame.lua:88-95` is its only 1.12 consumer and it re-runs the
/// **party-leader icon** — *not* an AFK/DND badge, which 1.12 has nowhere on a unit frame and has
/// no `UnitIsAFK`/`UnitIsDND` binding to draw from. With no producer, `TargetFrame_Update` was the
/// crown's only writer, so it moved on a re-target and at no other moment: leadership passing to
/// the player you were already holding did nothing on screen.
///
/// This drives the real stock file, so it is the symptom itself and not a proxy for it.
#[test]
fn the_target_leader_crown_follows_player_flags_changed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let mate = |leader: bool| {
Some(UnitState {
exists: true,
has_object: true,
name: Some("Groupmate".into()),
health: 100,
max_health: 100,
level: 30,
power_type: 0,
power: 50,
max_power: 50,
// `UnitIsPartyLeader`'s descriptor leg — PLAYER_FLAGS bit 0x1 — and the raw dword the
// event fires on, moved together the way the wire moves them.
group_leader: leader,
player_flags: u32::from(leader),
// **A NON-ZERO guid, or this test cannot fail.** `UnitIsPartyLeader` is two legs ORed
// and the second is a raw compare against the group's leader guid, with no zero guard
// — verified 1.12 behaviour, which is why `UnitIsPartyLeader(nil)` answers 1 while
// solo (`unit::bindings`). A default `UnitState` carries `guid: 0`, which equals the
// zeroed leader of an empty group, so the crown is up from the first frame and every
// assertion below passes for the wrong reason. Naming a guid puts the descriptor leg
// in sole charge, which is the leg this event exists to refresh.
guid: 0x4000_0000_0000_0009,
..UnitState::default()
})
};
let crown =
|s: &UiScript| -> bool { s.eval::<bool>("return TargetLeaderIcon:IsShown()").unwrap() };
// Target an ordinary group member: no crown.
s.set_unit("target", mate(false));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!crown(&s), "not the leader: no crown");
// **Leadership passes to them while you hold the target.** This is the whole report.
s.set_unit("target", mate(true));
// The control FIRST: the same event for a different token must change nothing, because the
// stock handler is gated on `arg1 == "target"`. A producer that fired argless — or that fired
// the wrong token — would light the crown here and the assertion below would prove nothing.
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("player".into())],
);
assert!(
!crown(&s),
"arg1 = \"player\" is not this frame's unit — the token gate is real"
);
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(crown(&s), "the crown appears without a re-target");
// And back down — the reference fires on the XOR-diff, so the clear edge is the same event.
s.set_unit("target", mate(false));
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(!crown(&s), "leadership leaves and the crown goes with it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}