the Ironforge lava returns, and the sound crackle is cornered

- the Great Forge's lava stays drawn from every angle, and the chasm catwalk reads as a connected grate again
- music decoding and shader-compile bursts stop crowding the audio's lane, and the output buffer follows the device's real deadline
- the audio path grows its instruments: a mix tap to WAV, a starvation meter, a hardware overload listener
- trainer detail icons carry the reference's own tooltip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:03:52 +07:00
co-authored by Claude Fable 5
parent 50aedb8b05
commit ba9a333aeb
37 changed files with 2835 additions and 1118 deletions
+33 -4
View File
@@ -60,10 +60,13 @@
//!
//! ## The tooltip channels
//!
//! `GameTooltip:SetCraftItem(craftIndex, reagentIndex)` / `GameTooltip:SetCraftSpell(craftIndex)` are
//! registered in [`super::tooltip_item`], beside `SetTradeSkillItem` — see that module's doc for the
//! v1 SetCraftSpell call (a plain two-line render off this module's own `name`/`description`, not a
//! second ask-once round trip through [`super::tooltip_spell`]).
//! `GameTooltip:SetCraftItem(craftIndex, reagentIndex)` is registered in [`super::tooltip_item`],
//! beside `SetTradeSkillItem`. `GameTooltip:SetCraftSpell(craftIndex)` lives in
//! [`super::tooltip_spell`] beside `SetTrainerService`, its structural twin: both are SELECTORS
//! into the shared spell/item builders rather than renderers of their own. The v1 two-line
//! "name white, description gold" render is gone — `SetCraftSpell 0x533e90` funnels into the same
//! `0x52e610`/`0x52b650` pair every other content binding does, so two lines were eight-plus short
//! (wow-re `ui/scratch/trainer-service-tooltip-law.md` §4.1).
use mlua::{Lua, MultiValue, Value, Variadic};
@@ -87,6 +90,26 @@ pub struct CraftReagent {
pub have: u32,
}
/// What `GameTooltip:SetCraftSpell` describes for one row — the pre-resolved output of the
/// app-side law (`ui_craft::craft_tooltip`, transcribing `SetCraftSpell 0x533e90`).
/// [`super::TrainerTooltip`]'s twin in shape and its opposite in law: this one tests the recipe's
/// **own** `Effect[i]` (24 `CREATE_ITEM` → the item, 36 `LEARN_SPELL` → the taught spell), never
/// `57`, never sets `altCaster`, and does **not** validate that the hop resolves before taking it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CraftTooltip {
/// The ITEM builder, on `EffectItemType[i]` of the matched `CREATE_ITEM` slot.
Item(u32),
/// The SPELL builder, on `EffectTriggerSpell[i]` of the matched `LEARN_SPELL` slot — or on the
/// recipe spell itself when no slot matched, which is every enchant.
Spell(u32),
}
impl Default for CraftTooltip {
fn default() -> Self {
CraftTooltip::Spell(0)
}
}
/// One recipe row (`GetCraftInfo` + its detail-pane getters): a known spell carrying attr
/// `SPELL_ATTR_IS_TRADESKILL`, resolved by the app from `Spell.dbc`/`SkillLineAbility.dbc`/bag counts —
/// [`super::tradeskill::TradeSkillRecipe`]'s twin, minus the product-item concept, plus `sub_name` +
@@ -127,6 +150,9 @@ pub struct CraftRecipe {
/// exposed to Lua as `GetCraftSpellFocus`'s alternating multivalue
/// ([`super::tradeskill::TradeSkillRecipe::tools`]'s twin).
pub tools: Vec<(String, bool)>,
/// What the detail-icon hover describes ([`CraftTooltip`]) — resolved app-side, because the law
/// reads `Spell.dbc` effect columns the engine cannot see.
pub tooltip: CraftTooltip,
}
/// One open craft window: the skill line's name/rank and its (app-presorted) recipe list. Pushed whole
@@ -441,6 +467,9 @@ mod tests {
) -> CraftRecipe {
CraftRecipe {
spell_id,
// The fixture's default subject: the recipe itself (the enchant case — no matching
// effect slot), so a test that cares about the item arm sets it explicitly.
tooltip: CraftTooltip::Spell(spell_id),
name: name.into(),
sub_name: String::new(),
difficulty,
+2 -2
View File
@@ -113,7 +113,7 @@ pub use char_stats::{
SKILL_DEFENSE, SKILL_UNARMED,
};
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use craft::{CraftReagent, CraftRecipe, CraftState};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
@@ -151,7 +151,7 @@ pub use trade::{TradeSideState, TradeSlotItem, TradeState, TRADE_SLOTS};
pub use tradeskill::{TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState};
pub use trainer::{
TrainerAbilityReq, TrainerGroup, TrainerService, TrainerServiceCategory, TrainerSkillReq,
TrainerState,
TrainerState, TrainerTooltip,
};
pub use types::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
@@ -498,3 +498,234 @@ fn action_hover_delegates_by_kind() {
assert_eq!(s.take_spell_tooltip_asks(), vec![5143], "the miss asked");
assert!(s.take_errors().is_empty());
}
/// The tooltip's left-column texts in order.
fn left_lines(s: &mut UiScript) -> Vec<String> {
let n: i64 = s.eval("return TT:NumLines()").unwrap();
(1..=n)
.map(|i| {
s.eval::<String>(&format!(
"return getglobal('TTTextLeft{i}'):GetText() or ''"
))
.unwrap()
})
.collect()
}
/// One trainer service fixture with an explicit tooltip subject.
fn trainer_service(spell_id: u32, name: &str, tooltip: TrainerTooltip) -> TrainerService {
TrainerService {
spell_id,
tooltip,
name: Some(name.into()),
texture: Some("Interface\\Icons\\Temp".into()),
category: TrainerServiceCategory::Available,
skill_line: 26,
skill_line_name: "Arms".into(),
..Default::default()
}
}
/// `SetTrainerService` is a SELECTOR: it renders no line of its own and routes to whichever shared
/// builder the app-side law picked. All three arms are pinned here — the item arm, the spell arm
/// (with `altCaster` suppressing the reagents block), and a header row, which is a no-op.
///
/// The index is a VISIBLE row index, so row 1 is the "Arms" header and the services start at 2 —
/// the same interleave `SetTradeSkillItem` has, and the same way to get it wrong.
#[test]
fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
2847,
ItemTemplateView {
name: "Copper Shortsword".into(),
quality: 1,
class: 2,
subclass: 7,
..Default::default()
},
);
s.set_spell_tooltip(
200,
SpellTooltipView {
name: "Heroic Strike".into(),
cost: Some("15 Rage".into()),
reagents: Some("Reagents: Linen Cloth".into()),
description: "A strong attack.".into(),
..Default::default()
},
);
s.set_trainer(Some(TrainerState {
services: vec![
trainer_service(2756, "Copper Shortsword", TrainerTooltip::Item(2847)),
trainer_service(
100,
"Heroic Strike",
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: false,
},
),
trainer_service(
101,
"Train Growl",
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: true,
},
),
],
..Default::default()
}));
s.run(
r#"
local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
CreateFrame("GameTooltip", "TT")
-- Row 1 is the "Arms" header: a no-op, not a shifted hit onto a neighbouring service.
TT:SetOwner(a, "ANCHOR_RIGHT")
TT:SetTrainerService(1)
assert(TT:NumLines() == 0, "a header row renders nothing")
-- Row 2: the recipe -> the ITEM builder, full item tooltip (not the spell's name).
TT:SetOwner(a, "ANCHOR_RIGHT")
TT:SetTrainerService(2)
assert(TTTextLeft1:GetText() == "Copper Shortsword", "the item arm renders the item")
assert(TT:NumLines() > 1, "the ITEM builder, not a one-line stub")
"#,
)
.unwrap();
// Row 3: the class-trainer service -> the SPELL builder on the TAUGHT spell, reagents shown.
s.run(
r#"
TT:SetOwner(AB1, "ANCHOR_RIGHT")
TT:SetTrainerService(3)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert_eq!(
texts[0], "Heroic Strike",
"the spell arm hops to the taught"
);
assert!(
texts.iter().any(|t| t == "Reagents: Linen Cloth"),
"altCaster clear -> the reagents block renders: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "A strong attack."),
"the gold description is part of the shared builder's law: {texts:?}"
);
// Row 4: the same view, altCaster set -> the reagents block is gone, everything else stays.
s.run(
r#"
TT:SetOwner(AB1, "ANCHOR_RIGHT")
TT:SetTrainerService(4)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert!(
!texts.iter().any(|t| t == "Reagents: Linen Cloth"),
"altCaster gates the reagents block: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "A strong attack."),
"and gates ONLY that block: {texts:?}"
);
assert!(s.take_errors().is_empty());
}
/// `SetCraftSpell` is `SetTrainerService`'s structural twin: a selector, not the two-line
/// name/description stub it used to be. Both arms are pinned, and the assertion that matters most
/// is the negative one — a rod recipe's hover shows the ROD's item tooltip while the same row's
/// ICON is the spell's (Law D, decision 1107). Icon and tooltip disagreeing on one row is the
/// verified shape, not a bug to reconcile.
#[test]
fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
6218,
ItemTemplateView {
name: "Runed Copper Rod".into(),
quality: 1,
..Default::default()
},
);
s.set_spell_tooltip(
7420,
SpellTooltipView {
name: "Enchant Bracer - Minor Health".into(),
cast_time: Some("Instant cast".into()),
description: "Permanently enchant bracers.".into(),
..Default::default()
},
);
let recipe = |spell_id: u32, name: &str, tooltip: CraftTooltip| CraftRecipe {
spell_id,
tooltip,
name: name.into(),
sub_name: String::new(),
difficulty: TradeSkillDifficulty::Optimal,
num_available: 1,
icon: Some("Interface\\Icons\\Spell_Holy_Heal".into()),
description: Some("the OLD two-line stub's second line".into()),
needs_item_target: false,
reagents: vec![],
tools: vec![],
};
s.set_craft(Some(CraftState {
name: "Enchanting".into(),
rank: 100,
max_rank: 150,
recipes: vec![
recipe(
7420,
"Enchant Bracer - Minor Health",
CraftTooltip::Spell(7420),
),
recipe(7421, "Runed Copper Rod", CraftTooltip::Item(6218)),
],
}));
s.run(
r#"
local a = CreateFrame("Button", "AB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
CreateFrame("GameTooltip", "TT")
TT:SetOwner(a, "ANCHOR_RIGHT")
TT:SetCraftSpell(1)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert_eq!(texts[0], "Enchant Bracer - Minor Health");
assert!(
texts.iter().any(|t| t == "Instant cast"),
"the SHARED spell builder's law, not a two-line stub: {texts:?}"
);
assert!(
!texts
.iter()
.any(|t| t == "the OLD two-line stub's second line"),
"the row's own `description` field is no longer the tooltip's source: {texts:?}"
);
s.run(
r#"
TT:SetOwner(AB2, "ANCHOR_RIGHT")
TT:SetCraftSpell(2)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert_eq!(
texts[0], "Runed Copper Rod",
"a CREATE_ITEM recipe hovers the ITEM, though its icon is the spell's: {texts:?}"
);
assert!(s.take_errors().is_empty());
}
@@ -42,7 +42,7 @@ use super::{ItemTemplateView, Model};
mod names;
mod render;
use names::{quality_color, GOLD, GRAY, WHITE};
use names::{quality_color, GRAY, WHITE};
use render::render_view;
/// Look up the store; a miss records the ask (the app sends `CMSG_ITEM_QUERY` and pushes back —
@@ -641,44 +641,5 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?,
)?;
// GameTooltip:SetCraftSpell(craftIndex) — the craft window's detail-icon hover (decision 0437
// phase 3, ref `CraftIcon`'s OnEnter, `Blizzard_CraftUI.xml:566`): a plain two-line render
// (name white, description gold) straight off THIS recipe's own pre-resolved fields, v1's
// deliberate choice over a second ask-once round trip through super::tooltip_spell's
// SpellTooltipView store (craft.rs's own module doc, "The tooltip channels") — CraftRecipe
// already carries the app-resolved description, so a second store keyed the same spell id
// under a different channel would just double the app's push traffic for the same string. An
// out-of-range craft index is a no-op.
m.set(
"SetCraftSpell",
lua.create_function(|lua, (this, craft_index): (Table, usize)| {
let found = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(c) = &model.craft else {
return Ok(());
};
craft_index
.checked_sub(1)
.and_then(|i| c.recipes.get(i))
.map(|r| (r.name.clone(), r.description.clone()))
};
let Some((name, description)) = found else {
return Ok(());
};
let h = frame_handle_of(lua, &this)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
append_line(lua, &this, (name, WHITE), None, false)?;
if let Some(desc) = description {
append_line(lua, &this, (desc, GOLD), None, true)?;
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
Ok(())
}
+159 -14
View File
@@ -25,7 +25,7 @@ use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared};
use super::Model;
use super::{CraftTooltip, Model, TrainerTooltip};
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
/// The rank column's gray — byte-verified `0xff808080` (0276).
@@ -136,15 +136,38 @@ const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
/// Render one spell view — the verified law (module doc). `aura` renders the aura variant:
/// white description, plus the caller-supplied duration-remaining line (`SetPlayerBuff` only).
/// `talent` interleaves the talent lines ([`TalentLines`] doc).
/// The builder's parameter vector, named as the byte law names it (`0x52e610`'s param3..param8 —
/// wow-re `ui/scratch/tooltip-content-law.md` §3). These were three positional `bool`s at a
/// 7-argument call site, which is exactly the shape that gets silently transposed; `Default` is the
/// plain spell hover every caller but two wants.
#[derive(Clone, Copy, Default)]
pub(super) struct SpellRenderOpts {
/// Render through the AURA builder (`0x52f880`) rather than the spell builder: gold name, white
/// description, the dispel-class right column.
pub(super) aura: bool,
/// `param6` showRank — the gray "Rank N" right column. `SetSpell` passes 0 (the spellbook hover
/// never shows it), `SetAction` passes 1.
pub(super) show_rank: bool,
/// `param5` altCaster — **one** gate suppressing **both** the totems and the reagents lines
/// (byte-verified at the two branch sites `0x52ed43` and `0x52f393`). Set only by
/// `SetTrainerService`, and only when the matched learn-wrapper slot was `LEARN_PET_SPELL`: a
/// pet-training service shows neither block.
pub(super) alt_caster: bool,
}
fn render_spell(
lua: &Lua,
this: &Table,
v: &SpellTooltipView,
aura: bool,
show_rank: bool,
opts: SpellRenderOpts,
remaining: Option<String>,
talent: Option<&TalentLines>,
) -> mlua::Result<()> {
let SpellRenderOpts {
aura,
show_rank,
alt_caster,
} = opts;
// The name colour splits by BUILDER, byte-verified: the spell builder `0x52e610` writes its
// name line through `0x530270` (white), the aura builder `0x52f880` through `0x530380` — the
// GOLD wrapper (wow-re tooltip-content-law §3 line 1 vs §3-BUFF). SetTrackingSpell's gold,
@@ -206,7 +229,9 @@ fn render_spell(
append_line(lua, this, (req.clone(), color), None, false)?;
}
// Reagents (law §3.8): white + wrapped, the missing entries inline-red inside the text.
if let Some(reagents) = &v.reagents {
// Suppressed wholesale by altCaster — the same gate that hides the totems block, which we
// have no feed for yet, so this is the only half of it that is observable here.
if let Some(reagents) = v.reagents.as_ref().filter(|_| !alt_caster) {
append_line(lua, this, (reagents.clone(), WHITE), None, true)?;
}
// Chance to dodge/parry/block/crit (law line 10, §3-CHANCE) — white, NOT wrapped, and it
@@ -266,7 +291,14 @@ pub(super) fn set_spell_with_talent(
super::talent::ask_next_rank(lua, talent.next_spell);
}
match spell_view_of(lua, spell_id) {
Some(v) => render_spell(lua, this, &v, false, false, None, Some(&talent))?,
Some(v) => render_spell(
lua,
this,
&v,
SpellRenderOpts::default(),
None,
Some(&talent),
)?,
None => {
// The view hasn't landed: show the talent head alone (the ask is recorded; the
// hover's re-enter repaints complete) — the spell channel's own fallback shape.
@@ -284,8 +316,7 @@ fn set_spell_by_id(
this: &Table,
spell_id: u32,
fallback_name: Option<String>,
aura: bool,
show_rank: bool,
opts: SpellRenderOpts,
remaining: Option<String>,
) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
@@ -295,7 +326,7 @@ fn set_spell_by_id(
}
fire_cleared(lua, h);
match spell_view_of(lua, spell_id) {
Some(v) => render_spell(lua, this, &v, aura, show_rank, remaining, None)?,
Some(v) => render_spell(lua, this, &v, opts, remaining, None)?,
None => {
if let Some(name) = fallback_name {
append_line(lua, this, (name, WHITE), None, false)?;
@@ -337,7 +368,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetShapeshift(index) — the stance-bar hover (the form's own spell tooltip).
@@ -351,7 +382,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetPetAction(index) — the pet-bar hover (decision 0982). Only ever reached for a
@@ -377,7 +408,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetPlayerBuff(index [, filter]) — the buff-bar hover: the aura variant (white
@@ -420,7 +451,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, true, false, remaining)
set_spell_by_id(
lua,
&this,
spell_id,
name,
SpellRenderOpts {
aura: true,
..Default::default()
},
remaining,
)
})?,
)?;
// GameTooltip:SetUnitBuff(unit, index) / SetUnitDebuff(unit, index) — the target frame's aura
@@ -445,7 +486,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// entry with spell id 0: content clears and the empty plate hides — never a
// stale previous tooltip left showing. Id 0 records no ask.
let (spell_id, name) = hit.unwrap_or((0, None));
set_spell_by_id(lua, &this, spell_id, name, true, false, None)
set_spell_by_id(
lua,
&this,
spell_id,
name,
SpellRenderOpts {
aura: true,
..Default::default()
},
None,
)
})?,
)?;
}
@@ -509,7 +560,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
let Some(a) = action else { return Ok(()) };
match a.kind {
0x00 => set_spell_by_id(lua, &this, a.action, None, false, true, None),
0x00 => set_spell_by_id(
lua,
&this,
a.action,
None,
SpellRenderOpts {
show_rank: true,
..Default::default()
},
None,
),
0x80 => {
// Route through the shared item renderer (the id-keyed entry).
let f: mlua::Function = this.get("SetItemById")?;
@@ -519,5 +580,89 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
})?,
)?;
// GameTooltip:SetTrainerService(index) — the trainer detail-icon hover (ref
// `Blizzard_TrainerUI.xml:452`, whose OnEnter is SetOwner(this,"ANCHOR_RIGHT") +
// SetTrainerService(ClassTrainerFrame.selectedService) + Show(); the LIST ROWS carry no tooltip
// at all). Byte-verified whole in wow-re `ui/scratch/trainer-service-tooltip-law.md`.
//
// The binding is a **selector, not a renderer**: `0x5338b0` emits no line of its own (verified
// negative — none of the four AddLine helpers appears in its extent) and hands one of the two
// shared builders a subject. The subject is decided app-side, because the law reads `Spell.dbc`
// fields the engine cannot see, and arrives pre-resolved as `TrainerService::tooltip`. All this
// does is pick the renderer — which is exactly what the reference binding does.
//
// `index` is a **VISIBLE** row index (headers interleave), resolved through the same mapping
// every other trainer getter uses; a header row is a no-op.
m.set(
"SetTrainerService",
lua.create_function(|lua, (this, index): (Table, usize)| {
let subject = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match super::trainer::service(&model, index) {
Some(s) => s.tooltip.clone(),
None => return Ok(()),
}
};
match subject {
// Route through the shared item renderer, the way the reference routes into
// `0x52b650`. No fallback name: an item id of 0 or a template still in flight
// renders an EMPTY tooltip, which is the builder's own early-out.
TrainerTooltip::Item(item_id) => {
let f: mlua::Function = this.get("SetItemById")?;
f.call::<()>((this.clone(), item_id))
}
TrainerTooltip::Spell {
spell_id,
alt_caster,
} => set_spell_by_id(
lua,
&this,
spell_id,
None,
SpellRenderOpts {
alt_caster,
..Default::default()
},
None,
),
}
})?,
)?;
// GameTooltip:SetCraftSpell(craftIndex) — the Craft window's detail-icon hover (ref
// `CraftIcon`'s OnEnter, `Blizzard_CraftUI.xml:566`). `SetTrainerService`'s structural twin and
// its law's opposite (wow-re `ui/scratch/trainer-service-tooltip-law.md` §4.1): a selector into
// the same two shared builders, deciding on the RECIPE's own effect columns rather than a
// taught spell's attributes. The subject arrives pre-resolved as `CraftRecipe::tooltip`.
//
// This replaced a v1 two-line "name white, description gold" render. That was wrong in both
// halves: `0x533e90` funnels into `0x52e610`/`0x52b650` like every other content binding, so
// two lines were eight-plus short, and on a `LEARN_SPELL` or `CREATE_ITEM` recipe it was
// describing the wrong subject entirely. `craft_index` is a raw recipe position — the Craft
// window is FLAT, with no headers to interleave (unlike the tradeskill list).
m.set(
"SetCraftSpell",
lua.create_function(|lua, (this, craft_index): (Table, usize)| {
let subject = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(c) = &model.craft else {
return Ok(());
};
match craft_index.checked_sub(1).and_then(|i| c.recipes.get(i)) {
Some(r) => r.tooltip.clone(),
None => return Ok(()),
}
};
match subject {
CraftTooltip::Item(item_id) => {
let f: mlua::Function = this.get("SetItemById")?;
f.call::<()>((this.clone(), item_id))
}
CraftTooltip::Spell(spell_id) => {
set_spell_by_id(lua, &this, spell_id, None, SpellRenderOpts::default(), None)
}
}
})?,
)?;
Ok(())
}
+50 -2
View File
@@ -107,6 +107,43 @@ pub struct TrainerAbilityReq {
pub met: bool,
}
/// What `GameTooltip:SetTrainerService` describes for one row — the pre-resolved output of the
/// **app-side** tooltip law (`ui_trainer::service_tooltip`, transcribing `SetTrainerService
/// 0x5338b0`). The engine holds no DBC, so the app decides the subject and the engine only picks a
/// renderer; the binding in the real client does exactly that too — it emits no line of its own and
/// is a three-way selector into the shared spell builder `0x52e610` or item builder `0x52b650`.
///
/// This is deliberately **not** derivable from [`TrainerService::texture`]: the tooltip and the icon
/// disagree, by design and in both directions (wow-re `ui/scratch/trainer-service-tooltip-law.md`
/// §6 — the icon needs a trainer-type gate the tooltip does not have, and the icon pins the *wire*
/// wrapper where the tooltip hops to the *taught* spell). On ~806 of the shipped corpus's trainer
/// services the reference client visibly shows one spell's icon above another spell's tooltip.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TrainerTooltip {
/// The ITEM builder, on this item id — the taught spell carries `SPELL_ATTR_IS_TRADESKILL`.
/// `0` (or a template still in flight) renders an EMPTY tooltip, which is the client's own
/// behaviour: the builder early-outs on a cache miss and the query is enqueued.
Item(u32),
/// The SPELL builder, on the taught spell — or on the wire spell itself when no learn-wrapper
/// slot resolved, the only path that describes the wrapper.
Spell {
spell_id: u32,
/// `param5` altCaster — ONE gate suppressing BOTH the totems and the reagents lines
/// (byte-verified at `0x52ed43` and `0x52f393`). Set exactly when the matched wrapper slot
/// was `SPELL_EFFECT_LEARN_PET_SPELL`.
alt_caster: bool,
},
}
impl Default for TrainerTooltip {
fn default() -> Self {
TrainerTooltip::Spell {
spell_id: 0,
alt_caster: false,
}
}
}
/// One trainer service row, resolved by the app from the wire `TrainerSpell` (decision 0237). Plain
/// data — position in [`TrainerState::services`] is the *unsorted* wire order; the 1-based index the
/// Lua uses is a position in the visible display tree ([`rows`], built from [`TrainerState::groups`]).
@@ -145,6 +182,9 @@ pub struct TrainerService {
pub skill_line: u32,
/// The skill line's localized display name (`SkillLine.dbc`) — the header row's text.
pub skill_line_name: String,
/// What the detail-icon hover describes ([`TrainerTooltip`]) — resolved app-side, because the
/// law reads `Spell.dbc` fields the engine cannot see. Independent of [`Self::texture`].
pub tooltip: TrainerTooltip,
}
/// One skill-line group in the display tree (decision 0247): the header's skill line + name and the
@@ -293,8 +333,10 @@ fn rows(model: &Model) -> Vec<Row> {
}
/// The service at a 1-based visible index, or `None` when that row is a **header** (or OOB / no
/// trainer) — so the getters/buy that read a service safely no-op on a header row.
fn service(model: &Model, index: usize) -> Option<&TrainerService> {
/// trainer) — so the getters/buy that read a service safely no-op on a header row. `pub(super)`:
/// `GameTooltip:SetTrainerService` lives in the tooltip channel and must resolve its index through
/// the same VISIBLE mapping, never a raw `services[]` position.
pub(super) fn service(model: &Model, index: usize) -> Option<&TrainerService> {
let n = index.checked_sub(1)?;
match rows(model).get(n)? {
Row::Service(si) => model.trainer.as_ref()?.services.get(*si),
@@ -675,6 +717,12 @@ mod tests {
) -> TrainerService {
TrainerService {
spell_id,
// The fixture's default subject: the wire spell, no hop (the "no learn wrapper"
// fallback); the tooltip tests set the arm they mean explicitly.
tooltip: TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: Some("Rank 1".into()),
texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")),