world books open, and anvils stop offering a cursor gear

- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:03:35 +07:00
co-authored by Claude Fable 5
parent 19461d1e0f
commit d16d33dfca
55 changed files with 2485 additions and 358 deletions
+11 -1
View File
@@ -209,9 +209,19 @@ impl UiScript {
alpha_gradient: data.alpha_gradient,
}
} else {
// The draw gate is the TEXTURE slot, never the colour (`0x7706e0`: `+0xcc`
// empty -> emit NOTHING — `texture-color-composition.md` §4, VERIFIED). A
// vertex colour is a tint on whatever texture exists; alone it is not
// drawable content — it survives `SetTexture(nil)` by design ("a tint
// outlives the art it was tinting") and used to leak out of here as a
// solid plate the moment the art was cleared (the white action buttons on
// a character switch; the 2026-07-10 grey wells were the same class).
let has_texture = data.texture.is_some() || data.fill.is_some();
QuadContent::Texture {
path: data.texture,
color: texture_color(data.fill, data.vertex_color),
color: has_texture
.then(|| texture_color(data.fill, data.vertex_color))
.flatten(),
additive: data.additive,
tex_coords: data.tex_coords,
circular: data.circular,
@@ -526,3 +526,51 @@ fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
"the vertex colour outlives the texel it was multiplying"
);
}
/// 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
/// it was tinting"), a cleared region used to leak its tint out of extract as a solid plate — an
/// occupied action button going empty on a character switch drew its surviving 1/1/1 usable-tint
/// as a solid WHITE square (decision 1108; the 2026-07-10 grey wells were the same class).
#[test]
fn a_vertex_colour_without_a_texture_draws_nothing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Owner")
f:SetPoint("BOTTOMLEFT", 0, 0)
f:SetWidth(100) f:SetHeight(50)
icon = f:CreateTexture("Icon", "ARTWORK")
icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
icon:SetVertexColor(1, 1, 1)
"#,
)
.unwrap();
s.resolve();
let drawn = |s: &UiScript| {
s.extract()
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path, color, .. }
if path.is_some() || color.is_some())
})
.count()
};
assert_eq!(drawn(&s), 1, "tinted art draws");
// The slot empties: the art clears, the tint stays (distinct storage) — and nothing draws.
s.run("icon:SetTexture(nil)").unwrap();
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return icon:GetVertexColor()")
.unwrap(),
(1.0, 1.0, 1.0, 1.0),
"the tint survives the clear"
);
assert_eq!(
drawn(&s),
0,
"no texture at +0xcc -> emit NOTHING, never a solid plate of the surviving tint"
);
}
@@ -260,3 +260,84 @@ fn set_hyperlink_renders_items_and_ignores_other_links() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// One tradeskill recipe fixture — the product id is `spell_id + 10_000`, so the tooltip's own
/// name names which recipe the channel actually landed on.
fn ts_recipe(spell_id: u32, name: &str, group: (u32, u32, &str)) -> TradeSkillRecipe {
TradeSkillRecipe {
group: Some((group.0, group.1, group.2.to_string())),
spell_id,
name: name.into(),
difficulty: TradeSkillDifficulty::Optimal,
num_available: 1,
icon: None,
min_made: 1,
max_made: 1,
cooldown_secs: None,
product_item: spell_id + 10_000,
product_inv_type: 0,
product_item_level: 0,
reagents: vec![TradeSkillReagent {
item: spell_id + 20_000,
name: Some(format!("{name} Reagent")),
icon: None,
need: 1,
have: 1,
}],
tools: vec![],
}
}
/// `SetTradeSkillItem`'s index is a **VISIBLE** row index, not a position in `recipes` — headers
/// interleave with rows, so the two differ the moment any group precedes the recipe. This is the
/// exact mis-index the tradeskill module doc once carried as a known gap: with one header above it,
/// visible row 4 is the SECOND group's recipe, and a raw `recipes[4-1]` lookup would land on the
/// first group's second recipe instead. Both the product channel and the reagent channel are
/// pinned, since the detail-icon and reagent-slot hovers are the two callers that pass a genuine
/// visible index straight through. A HEADER row is a no-op, not a shifted hit.
#[test]
fn set_trade_skill_item_indexes_visible_rows_not_raw_recipes() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"local tt = CreateFrame("GameTooltip", "TT")"#)
.unwrap();
// Two groups, two recipes each. Visible order: [1] "Bolts" header, [2] Alpha Bolt,
// [3] Beta Bolt, [4] "Cloak" header, [5] Alpha Cloak, [6] Beta Cloak.
s.set_trade_skill(Some(TradeSkillState {
line: 197,
line_name: "Tailoring".into(),
rank: 57,
max_rank: 75,
recipes: vec![
ts_recipe(1, "Beta Bolt", (1, 2, "Bolts")),
ts_recipe(2, "Alpha Bolt", (1, 2, "Bolts")),
ts_recipe(3, "Beta Cloak", (2, 1, "Cloak")),
ts_recipe(4, "Alpha Cloak", (2, 1, "Cloak")),
],
repeat_count: 1,
}));
assert_eq!(s.eval::<i64>("return GetNumTradeSkills()").unwrap(), 6);
// Visible row 5 is "Alpha Cloak" (spell 4) — its product is 10_004. A raw recipes[5-1] lookup
// would land on recipe index 4 (out of range, a silent no-op); recipes[4-1] on "Beta Cloak".
// `TTTextLeft1` need not exist when a call renders nothing (what an out-of-range RAW index
// does), so read it defensively — that case must surface as an assert_eq naming the row, not
// as a Lua nil-index panic three lines away from the claim.
let name_at = |s: &mut UiScript, row: i64, reagent: &str| -> String {
s.run(&format!("TT:SetTradeSkillItem({row}{reagent})"))
.unwrap();
s.eval::<String>("return TTTextLeft1 and TTTextLeft1:GetText() or '<nothing rendered>'")
.unwrap()
};
assert_eq!(name_at(&mut s, 5, ""), "Alpha Cloak");
assert_eq!(name_at(&mut s, 6, ""), "Beta Cloak");
assert_eq!(name_at(&mut s, 2, ""), "Alpha Bolt");
// The reagent channel takes the same mapping.
assert_eq!(name_at(&mut s, 5, ", 1"), "Alpha Cloak Reagent");
// A header row resolves to nothing — the hover is a no-op, so the previous render stands
// rather than a neighbouring recipe's tooltip appearing under the cursor.
assert_eq!(name_at(&mut s, 4, ""), "Alpha Cloak Reagent");
}
+5 -11
View File
@@ -71,7 +71,7 @@
//! VISIBLE index from that flat position on every read, [`super::skills`]'s own by-identity
//! persistence pattern adapted to a field this module can't change the type of).
//!
//! ## The tooltip channel — KNOWN GAP, not fixed here
//! ## The tooltip channel
//!
//! `GameTooltip:SetTradeSkillItem(skillIndex [, reagentIndex])` is registered in
//! [`super::tooltip_item`], beside `SetMerchantItem`, through the same id-keyed item renderer: with
@@ -80,16 +80,10 @@
//! the renderer's own name-only line — the recipe's name for the product channel — rather than a
//! no-op, since that fallback already exists and reads better than a blank hover.
//!
//! **As of this grouping landing, `tooltip_item`'s `SetTradeSkillItem` still indexes
//! [`TradeSkillState::recipes`] directly by the raw `skillIndex` it's called with** — it does NOT
//! route through [`recipe_at`]'s visible-index mapping (that module is outside this change's file
//! scope). The XML's list-row hover (`BenillaTradeSkillSkillButton_OnEnter`) is guarded here to
//! never call it on a header row, but the product-icon and reagent-slot hovers pass
//! `BenillaTradeSkillFrame.selectedSkill` — a genuine VISIBLE index of a selected recipe — straight
//! through, so once any group precedes the selected recipe's own group, those two hovers will show
//! the WRONG item. `tooltip_item::install`'s `SetTradeSkillItem` closure needs a follow-up edit to
//! resolve through [`recipe_at`] (now `pub(crate)` for exactly this) instead of `ts.recipes.get(...)`
//! directly.
//! `skillIndex` is a **VISIBLE** index and is resolved through [`recipe_at`] (`pub(crate)` for
//! exactly this), never a raw [`TradeSkillState::recipes`] position — headers interleave with rows
//! since the TU-B grouping landed, so a raw index would show the wrong item the moment any group
//! precedes the selected recipe's own. A header index resolves to `None` and the hover is a no-op.
use std::collections::HashSet;