the guild window opens: roster, ranks, notes and the message of the day

- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them
- setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back
- unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used
- the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:38:54 +07:00
co-authored by Claude Opus 5
parent 04340a6893
commit 8d3d805ec8
78 changed files with 13155 additions and 271 deletions
@@ -912,23 +912,23 @@ fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() {
);
}
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.**
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and since 1327 ours says yes.**
///
/// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is
/// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5
/// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the
/// verb reporting "no" is a real machine's answer, not a stub.
///
/// We have no desaturating shader, so nil is the honest reply: claiming support would suppress
/// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong.
/// The test pins the return shape rather than the state, because the return is what callers branch
/// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes
/// `not shaderSupported`.
/// benilla answered nil for exactly as long as nothing greyed. Decision 1327 gave the renderer the
/// luminance fold, so the honest answer flipped: a caller that asks for grey now gets grey, and
/// keeps its own tint instead of having it overwritten by the 0.5 fallback. The test pins the
/// return SHAPE, because the return is what callers branch on — `1`, not `true`, because the C
/// answer is `1|nil` and every reference consumer writes `not shaderSupported`.
///
/// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via
/// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded.
#[test]
fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
fn set_desaturated_reports_shader_support_and_does_not_raise() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#)
.unwrap();
@@ -936,11 +936,11 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
// Dewdrop's exact call — the one 98 addons reach. It must not raise.
s.run("DsTex:SetDesaturated(true)").unwrap();
// ...and it answers nil, so the reference's `not shaderSupported` branch is taken.
// ...and it answers truthy, so the reference's shader arm is taken.
assert!(
s.eval::<bool>("return DsTex:SetDesaturated(true) == nil")
s.eval::<bool>("return DsTex:SetDesaturated(true) == 1")
.unwrap(),
"shaderSupported must be nil (1|nil C shape), not false"
"shaderSupported must be 1 (1|nil C shape), not true"
);
assert_eq!(
s.eval::<i64>("return select('#', DsTex:SetDesaturated(true))")
@@ -949,11 +949,13 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
"one return value"
);
// The reference's own consumer, transcribed: a falsy answer greys via vertex colour.
// The reference's own consumer, transcribed: a truthy answer keeps the CALLER's tint, and the
// 0.5 fallback never fires. This is the half B162 turned on — the talent tree asks for
// `(1, 0.65, 0.65, 0.65)` and must get 0.65, greyscale, not a flat 0.5 colour multiply.
s.run(
r#"
local shaderSupported = DsTex:SetDesaturated(true)
local r, g, b = 1, 1, 1
local r, g, b = 0.65, 0.65, 0.65
if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end
DsTex:SetVertexColor(r, g, b)
"#,
@@ -962,7 +964,10 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys");
assert!(
(r - 0.65).abs() < 1e-6 && (g - 0.65).abs() < 1e-6 && (b - 0.65).abs() < 1e-6,
"the shader arm keeps the caller's tint, got {r},{g},{b}"
);
// Both spellings of "off" are off — nil and false.
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
@@ -534,6 +534,155 @@ fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
);
}
/// **`SetDesaturated` rides the extract, and only against real ART** (decision 1327).
///
/// The state was stored from the day the verb landed and read by nobody, which is why every
/// greyed-out affordance in the UI was a brightness tint (B162's talent tree). The flag now travels
/// on the quad, so this pins the two ends of that wire — set/clear — plus the one carve-out: a
/// PATHLESS solid has its colour folded into the quad's tint and draws against a 1x1 white texel,
/// so a shader that greys the texel would grey white and change nothing. Carrying the flag there
/// would read as honoured while doing nothing at all, so extract drops it.
#[test]
fn desaturation_rides_the_extract_for_art_and_never_for_a_solid() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "DsOwner")
f:SetPoint("BOTTOMLEFT", 0, 0)
f:SetWidth(100) f:SetHeight(50)
art = f:CreateTexture("DsArt", "ARTWORK")
art:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
art:SetAllPoints()
solid = f:CreateTexture("DsSolid", "OVERLAY")
solid:SetTexture(1, 0, 0)
solid:SetAllPoints()
"#,
)
.unwrap();
s.resolve();
let grey = |s: &UiScript, want_path: bool| {
s.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path, desaturated, ..
} if path.is_some() == want_path => Some(*desaturated),
_ => None,
})
.expect("the quad")
};
assert!(!grey(&s, true), "art starts full colour");
s.run("art:SetDesaturated(1) solid:SetDesaturated(1)")
.unwrap();
assert!(grey(&s, true), "the flag reaches the art quad");
assert!(
!grey(&s, false),
"a pathless solid never carries it — greying a white texel is a no-op dressed as a feature"
);
// And it clears — the reference's own `SetItemButtonDesaturated(button, nil)` restore.
s.run("art:SetDesaturated(nil)").unwrap();
assert!(!grey(&s, true), "clearing the flag restores full colour");
}
/// Read a Texture region's desaturation state straight off the model, by name.
///
/// It has no getter in Lua on purpose — `IsDesaturated` (`0x79c2c0`) is in wow-re's ledger but its
/// return shape is not carved, and inventing one to make a test convenient is how an unverified API
/// gets shipped (decision 1327's own residual). The extract quad is the other way to see it, but a
/// cleared texture emits no quad at all, which is exactly the case these tests need to observe.
fn desaturated(s: &UiScript, name: &str) -> bool {
let lua = s.lua();
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
let id = *model.region_names.get(name).expect("region name");
let h = *model.id_to_region.get(&id).expect("region handle");
model.region_data.get(&h).is_some_and(|d| d.desaturated)
}
/// **`SetTexture` clears the desaturation — except when the path does not actually change**
/// (wow-re `texture-desaturate-law.md` §2.3, VERIFIED; decision 1330).
///
/// `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture` writes it from a shader index the
/// Lua binding always passes as slot 0 (permanently NULL). Storing a desaturate boolean *beside*
/// the texture handle — which is what benilla did on 1327 — diverges on the single most common
/// FrameXML shape there is: a repaint that re-sets the icon and expects the grey to follow the art.
/// The same-path early-out (`0x770225`) is what makes the *idempotent* repaint keep its grey, and
/// it is the half a plausible implementation drops.
#[test]
fn set_texture_clears_desaturation_unless_the_path_is_unchanged() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "ClrOwner")
icon = f:CreateTexture("ClrIcon", "ARTWORK")
icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
icon:SetDesaturated(1)
"#,
)
.unwrap();
let grey = |s: &UiScript| desaturated(s, "ClrIcon");
// The idempotent repaint: same file, so the client returns before it can clear anything.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")"#)
.unwrap();
assert!(grey(&s), "re-setting the SAME art keeps the grey");
// A different file reaches the write and zeroes the slot.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Fire_Fireball")"#)
.unwrap();
assert!(!grey(&s), "a texture CHANGE clears the desaturation");
// The clear forms take the same leg (`test esi,esi` falls through to the write).
s.run("icon:SetDesaturated(1) icon:SetTexture(nil)")
.unwrap();
assert!(!grey(&s), "SetTexture(nil) clears it too");
// The COLOUR form is a different function (`0x770360`) and is not one of the field's writers.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep") icon:SetDesaturated(1)"#)
.unwrap();
s.run("icon:SetTexture(1, 0, 0)").unwrap();
assert!(grey(&s), "the colour form does not touch the shader slot");
}
/// **`SetDesaturated`'s argument truth table has two arms that read backwards** (wow-re
/// `texture-desaturate-law.md` §1.1, VERIFIED at `0x6f1c10`'s jump table; decision 1330).
///
/// `0x6f1c10(L, 2, default=1)` takes its DEFAULT on `LUA_TNONE`, so a bare `SetDesaturated()` greys
/// — the opposite of the `if flag then` an implementation writes without looking. And a number is
/// truncated to an int, so `SetDesaturated(0)` clears where truthiness would have greyed.
#[test]
fn set_desaturated_takes_the_clients_argument_truth_table() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "ArgOwner") tex = f:CreateTexture("ArgTex", "ARTWORK")"#)
.unwrap();
let grey = |s: &UiScript| desaturated(s, "ArgTex");
// NO ARGUMENT is ON — the LUA_TNONE default arm, not the nil arm.
s.run("ArgTex:SetDesaturated()").unwrap();
assert!(
grey(&s),
"a bare SetDesaturated() greys (LUA_TNONE default)"
);
s.run("ArgTex:SetDesaturated(nil)").unwrap();
assert!(!grey(&s), "nil clears");
// A number truncating to zero clears; a non-zero one greys.
s.run("ArgTex:SetDesaturated(1) ArgTex:SetDesaturated(0)")
.unwrap();
assert!(!grey(&s), "0 clears — the number arm truncates to int");
s.run("ArgTex:SetDesaturated(0.5)").unwrap();
assert!(!grey(&s), "0.5 truncates to 0 and clears");
// Both booleans, and Dewdrop's `true` (the call 98 corpus addons reach).
s.run("ArgTex:SetDesaturated(true)").unwrap();
assert!(grey(&s), "true greys");
s.run("ArgTex:SetDesaturated(false)").unwrap();
assert!(!grey(&s), "false clears");
}
/// The draw gate is the TEXTURE slot, never the colour (`texture-color-composition.md` §4,
/// VERIFIED): `0x7706e0` tests `+0xcc` and emits NOTHING when it is empty, whatever the vertex
/// colour holds. Since the tint deliberately survives `SetTexture(nil)` ("a tint outlives the art
@@ -422,3 +422,89 @@ fn owner_anchored_tooltip_clamps_to_screen() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// `0x52fa50`'s ladder as pure logic (law §3-BUFF-TIME-FORMAT) — the arm thresholds, the
/// ceil/truncate asymmetry, and the `_P1` pick — over SYNTHETIC templates, so this asserts the
/// mechanism and carries none of the reference's own eight strings. The real ones are read off the
/// player's install and exercised in `benilla::ui_script::buff_tests`.
#[test]
fn the_duration_ladder_ceils_every_arm_but_seconds() {
// Deliberately not the shipped wording: what is under test is which key is reached and what
// number fills it, never what the string says.
let table = |key: &str| -> Option<String> {
Some(
match key {
"T_DAYS" => "<%d day>",
"T_DAYS_P1" => "<%d days>",
"T_HOURS" => "<%d hour>",
"T_HOURS_P1" => "<%d hours>",
"T_MIN" => "<%d min>",
"T_MIN_P1" => "<%d mins>",
"T_SEC" => "<%d sec>",
"T_SEC_P1" => "<%d secs>",
_ => return None,
}
.to_string(),
)
};
let d = |ms: u32| crate::script::tooltip::duration_text(ms, "T", true, &table);
// The three arm boundaries, from the compare instructions — each is exact, and the arm below
// it counts in its own unit right up to the edge.
assert_eq!(d(86_400_000).as_deref(), Some("<1 day>"), "the day edge");
assert_eq!(
d(86_399_999).as_deref(),
Some("<24 hours>"),
"one ms under a day is still HOURS, and ceil takes it to 24"
);
assert_eq!(d(3_600_000).as_deref(), Some("<1 hour>"), "the hour edge");
assert_eq!(
d(3_599_999).as_deref(),
Some("<60 mins>"),
"the carve's own example: no '1 hour' until the hour is whole"
);
assert_eq!(d(60_000).as_deref(), Some("<1 min>"), "the minute edge");
assert_eq!(
d(61_000).as_deref(),
Some("<2 mins>"),
"the carve's own example: 61 s ceils to 2, it does not truncate to 1"
);
// The asymmetry that makes this one function rather than four format calls: roundUp reaches
// the top three arms only.
assert_eq!(
d(59_999).as_deref(),
Some("<59 secs>"),
"seconds TRUNCATE — a ceil here would read 60"
);
assert_eq!(d(5_400).as_deref(), Some("<5 secs>"), "5.4 s is 5, not 6");
assert_eq!(
d(400).as_deref(),
Some("<0 secs>"),
"the lapsing second: 0, and PLURAL — the reference's own last reading"
);
assert_eq!(d(0).as_deref(), Some("<0 secs>"), "a fully lapsed aura");
assert_eq!(
d(1_000).as_deref(),
Some("<1 sec>"),
"exactly one is singular"
);
// roundUp = 0 truncates the top arms too (the parameter is real, not a constant we folded).
assert_eq!(
crate::script::tooltip::duration_text(61_000, "T", false, &table).as_deref(),
Some("<1 min>"),
);
// A key the string table does not carry yields NO LINE rather than an invented one.
assert_eq!(
crate::script::tooltip::duration_text(1_000, "NOPE", true, &table),
None
);
// …and a family shipping only the singular falls back to it rather than vanishing.
let singular_only = |key: &str| (key == "S_SEC").then(|| "<%d s>".to_string());
assert_eq!(
crate::script::tooltip::duration_text(5_000, "S", true, &singular_only).as_deref(),
Some("<5 s>"),
);
}
@@ -1,6 +1,7 @@
//! The engine spell/aura tooltip channel (decision 0274 P2, law per 0276): the verified line
//! shapes — name|rank, ONE cost|range line, ONE casttime|cooldown line, the passive omission,
//! the aura variant (white description + the SetPlayerBuff-only remaining line), and SetAction's
//! the aura variant (white description + the SetPlayerBuff-only remaining line, itself gold —
//! B62), and SetAction's
//! pure delegation.
use super::common::script;
@@ -253,6 +254,12 @@ fn player_buff_hover_is_the_aura_variant() {
);
s.run(
r#"
-- The duration line's wording comes from the VM's GlobalStrings, which the app runs off
-- the player's own install at boot. This VM has no install, so the test supplies the pair
-- itself — deliberately NOT the shipped wording, because what is under test here is that
-- the engine reads the string table at all, not what the strings say.
SPELL_TIME_REMAINING_MIN = "<%d m>"
SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>"
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
@@ -260,7 +267,8 @@ fn player_buff_hover_is_the_aura_variant() {
tt:SetPlayerBuff(0)
assert(tt:NumLines() == 3, "name + description + remaining, got " .. tt:NumLines())
assert(TTTextLeft2:GetText() == "Intellect increased by 2.", "the AURA description column")
assert(TTTextLeft3:GetText() == "2 minutes remaining", "got " .. TTTextLeft3:GetText())
-- 90 s left ceils to 2 minutes, and 2 takes the _P1 twin.
assert(TTTextLeft3:GetText() == "<2 mm>", "got " .. TTTextLeft3:GetText())
"#,
)
.unwrap();
@@ -280,6 +288,16 @@ fn player_buff_hover_is_the_aura_variant() {
if t == "Arcane Intellect" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
});
assert!(gold_name, "aura name is gold, not white");
// …and so is the DURATION-REMAINING line — the same `0xffffd200`, not the description's
// white. Report B62: measured on the reporter's own 1.12.1 reference shot
// (`media/1530672450247856148`), the "29 minutes remaining" glyphs are exactly
// `(255, 210, 0)`, the same pixels as that shot's title row, while its description rows are
// `(255, 255, 255)`. Ours drew the line white, which is the whole report.
let gold_remaining = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "<2 mm>" && *c == [1.0, 210.0 / 255.0, 0.0, 1.0])
});
assert!(gold_remaining, "duration-remaining line is gold, not white");
assert!(s.take_errors().is_empty());
}
@@ -817,3 +835,106 @@ fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
);
assert!(s.take_errors().is_empty());
}
/// Law §3-BUFF-DURATION's **gate**: `0x532b00` skips the duration block on `untilCancelled`
/// (`532bda: 8b 46 0c` / `532bdf: 75 2d`) — never on "has this aura a duration yet". The three
/// cases below are the ones that separate the two questions, and the middle two are what the old
/// `duration > 0 && left > 0` gate got wrong.
#[test]
fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
1459,
SpellTooltipView {
name: "Arcane Intellect".into(),
aura_description: "Intellect increased by 2.".into(),
..Default::default()
},
);
s.tick(10.0);
s.run(
r#"
SPELL_TIME_REMAINING_MIN = "<%d m>"
SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>"
SPELL_TIME_REMAINING_SEC = "<%d s>"
SPELL_TIME_REMAINING_SEC_P1 = "<%d ss>"
BENILLA_ANCHOR = CreateFrame("Button", "BF2")
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
BENILLA_TIP = CreateFrame("GameTooltip", "TT2")
"#,
)
.unwrap();
let hover = r#"
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
BENILLA_TIP:SetPlayerBuff(0)
BENILLA_LINES = BENILLA_TIP:NumLines()
BENILLA_LAST = BENILLA_LINES == 3 and TT2TextLeft3:GetText() or ""
"#;
let mut show = |aura: AuraState| {
s.set_auras("player", Some(vec![aura]));
s.run(hover).unwrap();
(
s.eval::<i64>("return BENILLA_LINES").unwrap(),
s.eval::<String>("return BENILLA_LAST").unwrap(),
)
};
let base = AuraState {
spell_id: 1459,
name: Some("Arcane Intellect".into()),
helpful: true,
..Default::default()
};
// 1 · Permanent — and carrying a duration anyway, so only the FLAG can be doing the work.
// Title + description, nothing else.
assert_eq!(
show(AuraState {
duration: 1800.0,
expiration_time: 100.0,
until_cancelled: true,
..base.clone()
}),
(2, String::new()),
"untilCancelled suppresses the line even with a live duration on the record"
);
// 2 · Timed, but no duration packet has landed yet — the frame an aura appears. The old gate
// blanked this; the reference shows the line, counting from zero.
assert_eq!(
show(AuraState {
duration: 0.0,
expiration_time: 0.0,
until_cancelled: false,
..base.clone()
}),
(3, "<0 ss>".to_string()),
"a timed aura's first frames still carry the line"
);
// 3 · Lapsed — the reading the reference's own truncating seconds arm produces, which the old
// gate hid entirely.
assert_eq!(
show(AuraState {
duration: 30.0,
expiration_time: 9.6, // 0.4 s in the past at now = 10
until_cancelled: false,
..base.clone()
}),
(3, "<0 ss>".to_string()),
"a lapsed aura reads 0, it does not lose the line"
);
// Control: an ordinary live aura still counts, so none of the above is the line going missing
// for an unrelated reason.
assert_eq!(
show(AuraState {
duration: 1800.0,
expiration_time: 100.0,
until_cancelled: false,
..base
}),
(3, "<2 mm>".to_string()),
);
assert!(s.take_errors().is_empty());
}