- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
607 lines
27 KiB
Rust
607 lines
27 KiB
Rust
//! The engine **unit tooltip builder** (decision 0274 P3) — the byte-verified line law of
|
||
//! `0x529fe0` (wow-re `ui/scratch/tooltip-content-law.md`, the 0276 fold-back):
|
||
//!
|
||
//! - NAME (gold — FrameXML recolors `TextLeft1` by reaction on `UPDATE_MOUSEOVER_UNIT`, exactly
|
||
//! like the reference's `GameTooltip_UnitColor`; the guild line is likewise FrameXML's and
|
||
//! joins when guild data streams). A name still in flight reads `UNKNOWNOBJECT`, never an
|
||
//! empty line — the builder's name read is `0x609210`, the same resolver `UnitName` uses, and
|
||
//! its miss tail is the same string (decisions 2002/2040);
|
||
//! - the creature SUBTITLE ("Stable Master") — white;
|
||
//! - the LEVEL line, composed from three slots over the four `TOOLTIP_UNIT_LEVEL*` templates:
|
||
//! level text (`"??"` for a world boss, a much-higher hostile, or level ≤ 0 — the hostile
|
||
//! delta is INTERIM at +10 pending a byte pin of the comparison), the class slot (the creature
|
||
//! TYPE word for hostile/neutral creatures, `"Race Class"` for players, `"Corpse"` when dead),
|
||
//! and the type slot (the rank word `{"", Elite, Elite, Boss, ""}`; `"Player"` for players);
|
||
//! - the FACTION NAME ("Stormwind", white) — the builder-tail block `0x52a7a0..` the law's §2
|
||
//! order originally omitted: the app resolves it (`faction_name`), every gate applied;
|
||
//! - "PvP" (white) · "Skinnable" (**red**) · "Civilian" (green, `0x612550`: PvP-flagged +
|
||
//! query-civilian + HOSTILE + GREY/trivial — the dishonorable-kill warning) · "Leader"
|
||
//! (white, `0x6125c0`: PvP-flagged + query racial_leader);
|
||
//! - health on the attached status bar (`<name>StatusBar`, the template child below the plate),
|
||
//! refreshed by every `set_unit` push for the live token — the byte law's HEALTH watcher.
|
||
//!
|
||
//! The world mouseover drives this through [`super::UiScript::world_tooltip_unit`] (the engine
|
||
//! shows the tooltip at the default anchor by firing `OnTooltipSetDefaultAnchor`, renders, and
|
||
//! fires `UPDATE_MOUSEOVER_UNIT` for the Lua recolor) and [`super::UiScript::world_tooltip_fade`]
|
||
//! (the byte law ARMS a fade on hover loss, not an instant hide). Unit-frame hovers call the
|
||
//! same `SetUnit` from Lua.
|
||
|
||
use mlua::{Lua, Table};
|
||
|
||
use super::object::frame_handle_of;
|
||
use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty};
|
||
// The grey band + trivial/GREY check (`0x5f0700`, the CIVILIAN line's last gate: a green-or-
|
||
// better con never warns of a dishonorable kill) and the "??" gate live in one shared home
|
||
// (`unit.rs`), alongside `UnitLevel`'s −1 return and the `GetQuestGreenRange` binding.
|
||
use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject};
|
||
use super::{KindState, Model, UnitState};
|
||
use crate::layout::{Anchor, Point};
|
||
use crate::widget::FrameHandle;
|
||
|
||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||
const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||
/// `0xc0d420` = `0xff40c040` — the satisfiable-lock green (see [`TooltipTint::LockOpen`]).
|
||
const LOCK_OPEN: [f32; 4] = [64.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0, 1.0];
|
||
/// Unit names render gold (byte-verified `0xffffd200`); FrameXML recolors line 1 by reaction.
|
||
const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||
|
||
/// A GameObject tooltip line's colour, as the builder `0x52aa20` picks it (decision 0756). The
|
||
/// app half decides *which* line gets which tint from the lock law; this enum is only the
|
||
/// crate-boundary spelling, so the colour constants stay private to the UI engine.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum TooltipTint {
|
||
/// `0xc0cf60` — the requirement line's normal colour (the key-item "Requires %s").
|
||
White,
|
||
/// The "Locked" line's UNMET colour `0xc0d3a8` = `0xffff2020`, and the unknown-skill
|
||
/// "Requires %s" (whose own red is `0xffff0000` — a difference of 32/255 in G and B that no
|
||
/// eye resolves, so one slot serves both).
|
||
Red,
|
||
/// The "Locked" line when the lock **is** satisfiable — `0xc0d420` = `0xff40c040`
|
||
/// (64,192,64). Deliberately NOT the tooltip's ordinary green `0xc0d3ac` = `0xff00ff00`: this
|
||
/// is the second rung of the skill-difficulty ramp `0x529fa0` (grey/green/yellow/orange/red,
|
||
/// static-init writers at `0x5290d0` &c.), which the lock line borrows wholesale, and it is a
|
||
/// visibly softer green than the one every other tooltip line uses.
|
||
LockOpen,
|
||
}
|
||
|
||
/// The rank word table — byte-verified `0x854158[]`: rare-elite prints ELITE, rare prints
|
||
/// nothing (there is no distinct "Rare Elite" word in 1.12's builder).
|
||
fn rank_word(rank: u32) -> Option<&'static str> {
|
||
match rank {
|
||
1 | 2 => Some("Elite"),
|
||
3 => Some("Boss"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The level line — three slots over the four `TOOLTIP_UNIT_LEVEL*` templates ("Level %s" /
|
||
/// "Level %s %s" / "Level %s (%s)" / "Level %s %s (%s)", the extracted enUS strings).
|
||
fn level_line(u: &UnitState, player_level: u32) -> String {
|
||
// The "??" gate, byte-pinned (0x529fe0 §2-LEVEL): much-higher HOSTILE — internal reaction
|
||
// ≤ 1 = hated/hostile = UnitReaction ≤ 2 on our 1..8 API scale (the UnitIsEnemy mapping) —
|
||
// with playerLevel ≤ targetLevel−10; OR WorldBoss; OR level ≤ 0. Players NEVER read "??".
|
||
// Shared with `UnitLevel`'s −1 return, so the tooltip and the target frame can never
|
||
// disagree on who reads "??" ([`level_reads_unknown`]).
|
||
let level_text = if level_reads_unknown(u, player_level) {
|
||
"??".to_string()
|
||
} else {
|
||
u.level.to_string()
|
||
};
|
||
// The class slot: "Race Class" for players; the creature TYPE word for hostile/neutral
|
||
// creatures (a friendly creature shows none — the byte law's hostile/neutral-only gate);
|
||
// "Corpse" when dead.
|
||
let class_slot = if u.dead {
|
||
Some("Corpse".to_string())
|
||
} else if u.is_player {
|
||
match (&u.race, &u.class) {
|
||
(Some(r), Some(c)) => Some(format!("{r} {c}")),
|
||
(Some(r), None) => Some(r.clone()),
|
||
(None, Some(c)) => Some(c.clone()),
|
||
(None, None) => None,
|
||
}
|
||
} else if u.reaction != 0 && u.reaction <= 4 {
|
||
u.creature_type_name.clone()
|
||
} else {
|
||
None
|
||
};
|
||
// The type slot: the rank word, or "Player" for players.
|
||
let type_slot = if u.is_player {
|
||
Some("Player".to_string())
|
||
} else {
|
||
rank_word(u.rank).map(str::to_string)
|
||
};
|
||
match (class_slot, type_slot) {
|
||
(Some(c), Some(t)) => format!("Level {level_text} {c} ({t})"),
|
||
(Some(c), None) => format!("Level {level_text} {c}"),
|
||
(None, Some(t)) => format!("Level {level_text} ({t})"),
|
||
(None, None) => format!("Level {level_text}"),
|
||
}
|
||
}
|
||
|
||
/// Render the unit tooltip for `token`'s current snapshot; returns whether the unit existed.
|
||
fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let (unit, player_level) = {
|
||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
(
|
||
model.unit(token).cloned().filter(|u| u.exists),
|
||
model.player_req.level,
|
||
)
|
||
};
|
||
{
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
clear_content(&mut model, h);
|
||
}
|
||
fire_cleared(lua, h);
|
||
let Some(u) = unit else {
|
||
// No unit: drop the live token and leave the tooltip hidden-empty.
|
||
set_live_token(lua, h, None);
|
||
show_or_hide_empty(lua, h);
|
||
return Ok(false);
|
||
};
|
||
// The NAME line. A unit whose name query has not answered yet does NOT title an empty plate:
|
||
// the builder resolves the name through `CGUnit_C::GetUnitName 0x609210` (`0x52a187`), and
|
||
// every one of that function's misses — a creature whose `creaturecache.wdb` record
|
||
// (`CGUnit+0xb30`) is still null, a pet whose `petnamecache.wdb` row is absent or stale, a
|
||
// player row `namecache.wdb` has not answered — falls to the SAME
|
||
// `FrameScript_GetText("UNKNOWNOBJECT")` tail `UnitName` falls to. One seam, one resolver
|
||
// ([`unknownobject`], decisions 2002/2040), so the verb and the plate can never disagree.
|
||
//
|
||
// The builder has no counterpart to `UnitName`'s two nils: the `"player"` fast path is the
|
||
// *binding's* (`0x517083`, before any resolve), and a token resolving to GUID 0 never reaches
|
||
// a builder at all — the entry gate `0x468460(typemask 8)` hands back no object, `SetUnit`
|
||
// answers nil and no plate is drawn (the `unit` early-return above).
|
||
let title = match &u.name {
|
||
Some(n) => n.clone(),
|
||
None => unknownobject(lua)?.to_str()?.to_string(),
|
||
};
|
||
append_line(lua, this, (title, GOLD), None, false)?;
|
||
if let Some(sub) = &u.subtitle {
|
||
append_line(lua, this, (sub.clone(), WHITE), None, false)?;
|
||
}
|
||
append_line(
|
||
lua,
|
||
this,
|
||
(level_line(&u, player_level), WHITE),
|
||
None,
|
||
false,
|
||
)?;
|
||
// The faction-name line ("Stormwind", white) sits between the level line and "PvP" — the
|
||
// builder-tail block at `0x52a7a0`. The app resolved every gate into `faction_name`.
|
||
if let Some(faction) = &u.faction_name {
|
||
append_line(lua, this, (faction.clone(), WHITE), None, false)?;
|
||
}
|
||
if u.pvp {
|
||
append_line(lua, this, ("PvP".into(), WHITE), None, false)?;
|
||
}
|
||
if u.skinnable {
|
||
append_line(lua, this, ("Skinnable".into(), RED), None, false)?;
|
||
}
|
||
// CIVILIAN (green) — `0x612550`, whole: the unit's PvP bit + the query civilian flag + the
|
||
// unit is HOSTILE to the player (UnitReaction ≤ 2 — internal reaction < 2) + the kill would
|
||
// be GREY/trivial. PVP_RANK_CIVILIAN = "Civilian" (extracted GlobalStrings). The predicate
|
||
// itself lives in `unit` because `UnitPVPName` gates its own civilian arm on the same call.
|
||
if is_civilian_kill(&u, player_level) {
|
||
append_line(lua, this, ("Civilian".into(), GREEN), None, false)?;
|
||
}
|
||
// LEADER (white) — `0x6125c0`: the PvP bit + the query racial_leader flag, no other gate.
|
||
// PVP_RANK_LEADER = "Leader" (extracted GlobalStrings).
|
||
if u.racial_leader && u.pvp {
|
||
append_line(lua, this, ("Leader".into(), WHITE), None, false)?;
|
||
}
|
||
set_live_token(lua, h, Some(token.to_string()));
|
||
update_bar(lua, h, Some(&u));
|
||
show_or_hide_empty(lua, h);
|
||
Ok(true)
|
||
}
|
||
|
||
/// Mark the tooltip as world-hover-owned (the fade-on-loss gate).
|
||
fn set_world_owned(lua: &Lua, h: FrameHandle) {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
if let KindState::Tooltip(t) = &mut f.kind_state {
|
||
t.world_owned = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Remember (or drop) the tooltip's live unit token — the health watcher's key.
|
||
fn set_live_token(lua: &Lua, h: FrameHandle, token: Option<String>) {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
if let KindState::Tooltip(t) = &mut f.kind_state {
|
||
t.unit_token = token;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drive the `<name>StatusBar` child from a unit snapshot (`None` hides it) — the engine pushes
|
||
/// health, FrameXML's `HealthBar_OnValueChanged` colors the fill.
|
||
fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
|
||
let bar = {
|
||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let name = model.arena.frame(h).and_then(|f| f.name.clone());
|
||
name.and_then(|n| model.arena.lookup(&format!("{n}StatusBar")))
|
||
};
|
||
let Some(bar) = bar else { return };
|
||
let (changed, shown) = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let Some(f) = model.arena.frame_mut(bar) else {
|
||
return;
|
||
};
|
||
match (&mut f.kind_state, unit) {
|
||
(KindState::StatusBar(sb), Some(u)) => {
|
||
sb.min = 0.0;
|
||
sb.max = u.max_health.max(1) as f32;
|
||
let v = u.health.min(u.max_health.max(1)) as f32;
|
||
let changed = (sb.value - v).abs() > f32::EPSILON;
|
||
sb.value = v;
|
||
(changed, true)
|
||
}
|
||
_ => (false, false),
|
||
}
|
||
};
|
||
{
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.arena.set_shown(bar, shown);
|
||
}
|
||
if changed {
|
||
let (id, value) = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let v = match model.arena.frame(bar).map(|f| &f.kind_state) {
|
||
Some(KindState::StatusBar(sb)) => sb.value,
|
||
_ => 0.0,
|
||
};
|
||
(model.frame_id(bar), v)
|
||
};
|
||
if let Err(e) = super::event::fire_widget_handler(
|
||
lua,
|
||
id,
|
||
"OnValueChanged",
|
||
vec![mlua::Value::Number(f64::from(value))],
|
||
) {
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.errors
|
||
.push(e.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The `set_unit` push hook: a fresh snapshot for the tooltip's LIVE token re-drives the health
|
||
/// bar (the byte law's HEALTH UpdateField watcher — the bar tracks without a line rebuild).
|
||
pub(super) fn on_unit_push(lua: &Lua, token: &str) {
|
||
let hits: Vec<FrameHandle> = {
|
||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
// The arena's tooltip registry (1634), gated by the resolve's roster exactly as the walk
|
||
// it replaces was — `update_bar` writes layout.
|
||
model
|
||
.arena
|
||
.tooltip_kinds()
|
||
.iter()
|
||
.copied()
|
||
.filter(|&h| {
|
||
model.frame_to_id.contains_key(&h)
|
||
&& matches!(
|
||
model.arena.frame(h).map(|f| &f.kind_state),
|
||
Some(KindState::Tooltip(t)) if t.unit_token.as_deref() == Some(token)
|
||
)
|
||
})
|
||
.collect()
|
||
};
|
||
for h in hits {
|
||
let unit = {
|
||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.unit(token).cloned().filter(|u| u.exists)
|
||
};
|
||
update_bar(lua, h, unit.as_ref());
|
||
}
|
||
}
|
||
|
||
impl super::UiScript {
|
||
/// Show the world-mouseover unit tooltip (the engine-driven flow the byte law describes):
|
||
/// fire `OnTooltipSetDefaultAnchor` (FrameXML seats the plate at the default corner), render
|
||
/// `token`'s snapshot, fire `UPDATE_MOUSEOVER_UNIT` (FrameXML recolors the name line by
|
||
/// reaction). Call on hover-target CHANGE — the health bar tracks pushes in between; a
|
||
/// re-show mid-fade resurrects at full alpha. `false` = no tooltip frame / no such unit.
|
||
pub fn world_tooltip_unit(&mut self, token: &str) -> bool {
|
||
let h = {
|
||
let mut model = self.model_mut();
|
||
let Some(h) = model.arena.lookup("GameTooltip") else {
|
||
return false;
|
||
};
|
||
let id = model.frame_id(h);
|
||
(h, id)
|
||
};
|
||
let (h, id) = h;
|
||
if let Err(e) =
|
||
super::event::fire_widget_handler(&self.lua, id, "OnTooltipSetDefaultAnchor", vec![])
|
||
{
|
||
self.push_error(e);
|
||
}
|
||
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
|
||
Ok(w) => w,
|
||
Err(e) => {
|
||
self.push_error(e);
|
||
return false;
|
||
}
|
||
};
|
||
let shown = match render_unit(&self.lua, &wrapper, token) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
self.push_error(e);
|
||
false
|
||
}
|
||
};
|
||
if shown {
|
||
set_world_owned(&self.lua, h);
|
||
// Cancel any running fade at full alpha (a re-hover resurrects).
|
||
let mut model = self.model_mut();
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
if let KindState::Tooltip(t) = &mut f.kind_state {
|
||
if t.fade_start.take().is_some() {
|
||
model.arena.set_alpha(h, 1.0);
|
||
}
|
||
}
|
||
}
|
||
drop(model);
|
||
self.fire_event("UPDATE_MOUSEOVER_UNIT", vec![]);
|
||
}
|
||
shown
|
||
}
|
||
|
||
/// Show the world-mouseover GAMEOBJECT tooltip — the byte-verified GO builder `0x52aa20`
|
||
/// (decisions 0276 / **0756**): the NAME (gold) followed by the lock lines the caller
|
||
/// resolved, each with its own tint.
|
||
///
|
||
/// **The anchor FORKS per object — it is not uniform** (decision 0766, correcting 0756). The
|
||
/// publisher's GameObject leg calls the picked object's `[obj->vtbl+0x5c]` and branches:
|
||
///
|
||
/// - **true** (`0x492a01`) → `0x52ffe0(owner, 6, 0, 0)` — anchor-state **6**, the *cursor*
|
||
/// anchor. Corroborated by the loss path: `0x530ae0` hides an anchor-state-6 tooltip
|
||
/// **immediately** instead of arming the fade, which is how a pointer-following label behaves.
|
||
/// - **false** (`0x492a42`) → no `SetOwner` at all; the plate keeps its default corner seat.
|
||
///
|
||
/// The **unit** leg (`0x492983`) never consults `+0x5c` — units are always corner-seated, which
|
||
/// is why the fork went unnoticed when 0756 made every GameObject corner-seated too. `cursor`
|
||
/// carries the caller's verdict: `Some(ui_xy)` = the cursor arm (and then
|
||
/// [`Self::world_tooltip_move`] follows the pointer), `None` = the corner.
|
||
///
|
||
/// Same fade lifecycle as the unit flow (no `UPDATE_MOUSEOVER_UNIT` — that recolor is the
|
||
/// unit line's).
|
||
pub fn world_tooltip_gameobject(
|
||
&mut self,
|
||
name: &str,
|
||
lines: &[(String, TooltipTint)],
|
||
cursor: Option<(f32, f32)>,
|
||
) -> bool {
|
||
let (h, id, root_id) = {
|
||
let mut model = self.model_mut();
|
||
let Some(h) = model.arena.lookup("GameTooltip") else {
|
||
return false;
|
||
};
|
||
let Some(root) = model.arena.lookup("UIParent") else {
|
||
return false;
|
||
};
|
||
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
|
||
(h, id, root_id)
|
||
};
|
||
match cursor {
|
||
// The cursor arm: seated centred above the pointer, clamped by the tooltip frame's own
|
||
// flag. Compare-then-touch so a still pointer never re-layouts.
|
||
Some((ui_x, ui_y)) => {
|
||
let mut model = self.model_mut();
|
||
let input = model.layout_inputs.entry(h).or_default();
|
||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||
let same = input.anchors.len() == 1
|
||
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||
if !same {
|
||
input.anchors = vec![new];
|
||
model.touch_layout();
|
||
}
|
||
}
|
||
// The corner arm: the same default-anchor handler the unit flow fires.
|
||
None => {
|
||
if let Err(e) = super::event::fire_widget_handler(
|
||
&self.lua,
|
||
id,
|
||
"OnTooltipSetDefaultAnchor",
|
||
vec![],
|
||
) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
}
|
||
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
|
||
Ok(w) => w,
|
||
Err(e) => {
|
||
self.push_error(e);
|
||
return false;
|
||
}
|
||
};
|
||
let render = || -> mlua::Result<()> {
|
||
{
|
||
let mut model = self.lua.app_data_mut::<Model>().expect("model app_data");
|
||
clear_content(&mut model, h);
|
||
}
|
||
fire_cleared(&self.lua, h);
|
||
append_line(&self.lua, &wrapper, (name.to_string(), GOLD), None, false)?;
|
||
for (text, tint) in lines {
|
||
let colour = match tint {
|
||
TooltipTint::White => WHITE,
|
||
TooltipTint::Red => RED,
|
||
TooltipTint::LockOpen => LOCK_OPEN,
|
||
};
|
||
append_line(&self.lua, &wrapper, (text.clone(), colour), None, false)?;
|
||
}
|
||
Ok(())
|
||
};
|
||
if let Err(e) = render() {
|
||
self.push_error(e);
|
||
return false;
|
||
}
|
||
set_world_owned(&self.lua, h);
|
||
{
|
||
let mut model = self.model_mut();
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
if let KindState::Tooltip(t) = &mut f.kind_state {
|
||
if t.fade_start.take().is_some() {
|
||
model.arena.set_alpha(h, 1.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
show_or_hide_empty(&self.lua, h);
|
||
true
|
||
}
|
||
|
||
/// Show the minimap BLIP tooltip — a landmark's `AreaPOI` name or a quest-dot NPC's name,
|
||
/// one GOLD line (the reference's engine SetText gold; a cross-interior dot renders FAINT
|
||
/// gold — the byte law's `|cffb0b0b0` wrap modulating the gold base, director-matched),
|
||
/// seated centred ABOVE the cursor: the tooltip's BOTTOM at the given UI-space point. The
|
||
/// plate FOLLOWS the pointer — `Self::minimap_tooltip_move` re-seats it as the cursor
|
||
/// drifts within one blip. Same world-owned fade lifecycle as the mouseover tooltip:
|
||
/// hover loss arms [`Self::world_tooltip_fade`].
|
||
pub fn minimap_tooltip(&mut self, text: &str, ui_x: f32, ui_y: f32, grey: bool) -> bool {
|
||
let (h, id, root_id) = {
|
||
let mut model = self.model_mut();
|
||
let Some(h) = model.arena.lookup("GameTooltip") else {
|
||
return false;
|
||
};
|
||
let Some(root) = model.arena.lookup("UIParent") else {
|
||
return false;
|
||
};
|
||
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
|
||
(h, id, root_id)
|
||
};
|
||
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
|
||
Ok(w) => w,
|
||
Err(e) => {
|
||
self.push_error(e);
|
||
return false;
|
||
}
|
||
};
|
||
{
|
||
let mut model = self.model_mut();
|
||
clear_content(&mut model, h);
|
||
let input = model.layout_inputs.entry(h).or_default();
|
||
// Anchor only — the screen clamp (G flags bit4: a cursor-seated plate near the window
|
||
// edge slides back in instead of clipping, director's report at the screen-right
|
||
// minimap) is the tooltip FRAME's own flag (`Frame::clamped_to_screen`), synced with
|
||
// live extents at every resolve.
|
||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||
// Compare-then-touch: a still cursor re-seats the plate at the same point every
|
||
// frame — tier 1 of the layout gate stays quiet unless the pointer actually moved.
|
||
let same =
|
||
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||
if !same {
|
||
input.anchors = vec![new];
|
||
model.touch_layout();
|
||
}
|
||
}
|
||
fire_cleared(&self.lua, h);
|
||
// The blip name renders GOLD like the reference (the engine tooltip's SetText line —
|
||
// the same 0xffffd200 the unit-name line uses); a cross-interior entry renders the
|
||
// byte law's `|cffb0b0b0` wrap MODULATING that gold — faint gold, not flat grey
|
||
// (director-matched against the reference, 2026-07-13).
|
||
let dim = 0xb0 as f32 / 255.0;
|
||
let color = if grey {
|
||
[GOLD[0] * dim, GOLD[1] * dim, GOLD[2] * dim, 1.0]
|
||
} else {
|
||
GOLD
|
||
};
|
||
if let Err(e) = append_line(&self.lua, &wrapper, (text.to_string(), color), None, false) {
|
||
self.push_error(e);
|
||
return false;
|
||
}
|
||
set_world_owned(&self.lua, h);
|
||
{
|
||
let mut model = self.model_mut();
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
if let KindState::Tooltip(t) = &mut f.kind_state {
|
||
if t.fade_start.take().is_some() {
|
||
model.arena.set_alpha(h, 1.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
show_or_hide_empty(&self.lua, h);
|
||
true
|
||
}
|
||
|
||
/// Re-seat the showing cursor-anchored tooltip at a new pointer point (the follow half of
|
||
/// [`Self::minimap_tooltip`] and [`Self::world_tooltip_gameobject`]): anchor-only — no
|
||
/// content rebuild, no fade churn. No-op when the tooltip isn't the world-owned shown plate.
|
||
pub fn world_tooltip_move(&mut self, ui_x: f32, ui_y: f32) {
|
||
let mut model = self.model_mut();
|
||
let Some(h) = model.arena.lookup("GameTooltip") else {
|
||
return;
|
||
};
|
||
let Some(root) = model.arena.lookup("UIParent") else {
|
||
return;
|
||
};
|
||
let owned_shown = model
|
||
.arena
|
||
.frame(h)
|
||
.map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown)
|
||
.unwrap_or(false);
|
||
if !owned_shown {
|
||
return;
|
||
}
|
||
let root_id = model.frame_id(root);
|
||
let input = model.layout_inputs.entry(h).or_default();
|
||
// Anchor only — the frame's own clamp flag keeps the plate on screen (see
|
||
// `Frame::clamped_to_screen`).
|
||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||
// Compare-then-touch, as in the seat/blip paths above: the follow fires per pointer
|
||
// event, and a still cursor must not dirty the layout gate's tier 1.
|
||
let same =
|
||
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||
if !same {
|
||
input.anchors = vec![new];
|
||
model.touch_layout();
|
||
}
|
||
}
|
||
|
||
/// Arm the mouseover tooltip's fade-out (hover loss — the byte law arms a timestamped fade,
|
||
/// never an instant hide). No-op when the tooltip isn't showing a world unit.
|
||
pub fn world_tooltip_fade(&mut self) {
|
||
let now = self.now();
|
||
let mut model = self.model_mut();
|
||
let Some(h) = model.arena.lookup("GameTooltip") else {
|
||
return;
|
||
};
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
if let KindState::Tooltip(t) = &mut f.kind_state {
|
||
if t.world_owned && f.shown && t.fade_start.is_none() {
|
||
t.fade_start = Some(now);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Register the unit content channel into the GameTooltip kind method table.
|
||
pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||
// GameTooltip:SetUnit(token) → 1/nil — the unit-frame hover (ref UnitFrame_OnEnter reads
|
||
// the return to arm its re-poll).
|
||
m.set(
|
||
"SetUnit",
|
||
lua.create_function(|lua, (this, token): (Table, String)| {
|
||
// Same gate as every `Unit*` verb: `SetUnit` resolves its argument through the client's
|
||
// one token resolver, so an unrecognised name raises here too rather than drawing an
|
||
// empty tooltip. NOT inside `render_unit` — the app's own push calls that with a
|
||
// canonical token and must never be gated against itself.
|
||
crate::script::unit::check_unit_token(&Some(token.clone()))?;
|
||
let ok = render_unit(lua, &this, &token)?;
|
||
Ok(if ok {
|
||
mlua::Value::Integer(1)
|
||
} else {
|
||
mlua::Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
Ok(())
|
||
}
|