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
@@ -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");
}