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:
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
Reference in New Issue
Block a user