Comments in benilla-app, the UI windows: one line of fact each, no history, no record numbers (#284)

Part of #41: the second of five `benilla-app` pieces. This one is the UI windows: chat, the NPC windows (gossip, merchant, trainer, taxi, bank, mail, auction, quest giver), the unit, party, raid and aura feeds, the action bar, bags, tooltips, macros, the spellbook, the world map and the smaller panels. Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 26,648 comment lines become 9,221.

Fixes #98.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - Several client addresses carried a `0x400000` slip (`UPDATE_MACROS` is `0x852460`, not `0x452460`; the macro store's globals likewise).
  - `QUEST_WATCH_UPDATE` carries the quest-log row (`0x4df880` scans the log table at `0xbb71c0`), not a watch-list position.
  - The stock `ChatFrame_DisplayTimePlayed` now ships, so benilla's own `/played` lines duplicate it (#194); trainer refusals are logged, not shown on the error line; the player's cast result is `SMSG_CAST_RESULT`.
  - Stock FrameXML and `Blizzard_*` line citations are checked against the stock 1.12 files, and vmangos line citations against the vmangos revision nearest the one the local server runs.
  - Dozens more turned up on the way: docs sitting on the wrong item, removed functions, stale counts, broken intra-doc links.
  - One test comment in `ui_script/questlog_tests.rs`, which landed with #184, carried the same watch-index slip and gets the same fix.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour, or one whose reference side is untraced, says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#188, #204, #222 and #259 among them). The comments here state what the code does. String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-25 14:54:23 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a2629b623d
commit 2f323ff0b3
138 changed files with 8661 additions and 26114 deletions
+114 -362
View File
@@ -1,15 +1,6 @@
//! The world-mouseover tooltip system (decision 0274 P3) — the app half of the byte-verified
//! mouseover flow (0276): the engine rebuilds the tooltip **once per hover-target change**
//! (`world_tooltip_unit` / `world_tooltip_gameobject`: default anchor via
//! `OnTooltipSetDefaultAnchor`, the verified line laws, `UPDATE_MOUSEOVER_UNIT` for the unit
//! recolor), the health bar tracks the per-frame `set_unit` pushes in between (the HEALTH
//! watcher), and hover loss ARMS a fade (`world_tooltip_fade`) rather than hiding.
//!
//! The picks are the byte-verified pair the cursor already rides: [`Hovered`] (units) and
//! [`HoveredObject`] (GameObjects), arbitrated by [`go_is_nearest`] exactly like the click
//! router. A hovered GameObject shows its template name (gold) and, when flag-locked, the red
//! Lock.dbc requirement lines ("Requires <key item>" / "Requires <skill>") — the verified
//! `0x52aa20` law. The standalone-corpse builder joins when corpse objects stream.
//! The world-mouseover tooltip and the spell tooltip views. The world tooltip rebuilds once per
//! hover-target change and a lost hover arms a fade; the pick is [`Hovered`] or
//! [`HoveredObject`], arbitrated by [`go_is_nearest`] as the click router does.
use crate::ui_items::{count_of, InventoryScope};
use bevy::prelude::*;
@@ -41,62 +32,40 @@ impl Plugin for UiTooltipPlugin {
}
}
/// Everything a view needs beyond the spell catalogs — the player-dependent halves of the line
/// law: the worn set (the equipped-item test, `0x5f0c50`), the bags (the reagent possession, and
/// the item-name cache the reagent names come from), the current form, and the bind point `$z`
/// substitutes against.
/// The player-dependent inputs of a spell view: the worn set (`0x5f0c50`), the bags, the form and
/// the bind point `$z` names.
struct ViewCtx<'a, 'w, 's> {
home_area: Option<&'a str>,
form: u8,
store: Option<&'a ObjectStore>,
/// The caster's own `UNIT_FIELD_COMBATREACH`. 1.5 is the descriptor default, not a guess.
/// The caster's `UNIT_FIELD_COMBATREACH`; 1.5 is the descriptor default.
combat_reach: f32,
/// The reach of whoever the caster is currently auto-attacking, when it is auto-attacking —
/// the melee range arm's second term. The tooltip passes no target, but `0x6e3480`'s melee
/// arm resolves `[caster+0xc48]` itself, so this cell moves with the mob you are swinging at
/// and falls back to doubling the caster's own reach when nothing is engaged.
/// The auto-attack target's reach, which `0x6e3480` reads itself (`[caster+0xc48]`); with
/// none, the caster's reach counts twice.
attack_target_reach: Option<f32>,
/// The one object index (2334) — what the possession cells resolve an instance guid
/// through: the worn-item line's search and each reagent's carried count.
/// The object index the worn-item search and each reagent's carried count resolve through.
objects: &'a Objects<'w, 's>,
items: &'a mut Items,
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
/// The talent spell-modifier tables — the cost cell shows the RESOLVED cost, which since
/// `SPELLMOD_COST` landed means the modified one (`crate::spell::usable::power_cost`).
/// The talent spell-modifier tables: the cost cell shows the modified cost (`power_cost`).
spell_mods: &'a crate::spell::SpellModifiers,
/// The VM's own `GlobalStrings.lua` (decision 2045) — every cell this builder composes is a
/// key, and this is where they resolve. `text` is the `%d`-filling twin the `$`-engine's
/// keyed tokens take (`benilla_formats::TokenContext::text`).
/// The VM's `GlobalStrings.lua`, where the keyed cells resolve; `text` is the `%d`-filling form
/// the `$` tokens take.
get: &'a dyn Fn(&str) -> Option<String>,
text: &'a dyn Fn(&str, &[i64]) -> Option<String>,
}
/// Fill a key's template out of the VM's own `GlobalStrings.lua`, or nothing at all when the
/// chain has no string for it — the reference's data-suppression face, and the reason no cell
/// here carries a fallback sentence (decision 2045).
/// Fill a key's template from the VM's `GlobalStrings.lua`; no string shows nothing.
fn keyed(get: &dyn Fn(&str) -> Option<String>, key: &str, args: &[Arg<'_>]) -> Option<String> {
let text = benilla_ui::strings::fill(&get(key)?, args);
(!text.is_empty()).then_some(text)
}
/// Build one spell's tooltip view (decision 0274 P2) — the verified spell line law's inputs,
/// every string resolved here where the catalogs live: the cost cell (the RESOLVED
/// `power_cost` through the power-type key array `0x85416c`, health fallback, `_PER_TIME`
/// composite — 1074; rage prints wire-cost ÷ 10), the range cell ("N yd range", "N-M yd range" when
/// the resolved min is nonzero — the law's `"%d-%d"` fork; the melee family resolves through the
/// same key off the caster's combat reach, and the on-next-swing class shows no cell at all), the cast cell
/// (the full `52eb45` ladder: sec/min, the negative-base sentinel, "Next melee"/"Attack
/// speed"/"Channeled", and the mana-keyed Instant fork — 1074; None = the law's
/// passive gate, which omits the whole line), the cooldown cell
/// (`max(RecoveryTime, CategoryRecoveryTime)` at `0x52eada`), the required-item and
/// required-form lines (`[0x52eea7, 0x52f2ae)`), the reagents line (`SPELL_REAGENTS 0x854e54`),
/// and the $-substituted description/aura-description (byte-exact formulas,
/// `benilla_formats::substitute`).
///
/// Line TEXT is composed in English here, as every cell in this builder is; the reference reads
/// its own GlobalStrings templates ("Reagents: ", "Requires %s"). That is this builder's standing
/// INTERIM shape, not a new debt introduced by these lines.
/// Build one spell's tooltip view, every cell resolved here where the catalogs live. The range,
/// cast, cooldown, form and item cells fill their `GlobalStrings.lua` keys; the cost, reagents and
/// chance cells are composed in English, where the reference fills `MANA_COST` and its kin,
/// `SPELL_REAGENTS` and `CHANCE_TO_*`.
fn spell_tooltip_view(
spell_id: u32,
spells: &Spells,
@@ -112,24 +81,15 @@ fn spell_tooltip_view(
home_area,
text: vctx.text,
};
// The cost cell (the `0x52e8ad` caller): the RESOLVED cost — `GetPowerCost`'s
// number, the same `power_cost` the usable walk compares (0948) — through the power-type key
// array (`0x85416c`: Mana/Rage/Focus/Energy/Happiness) with HEALTH as the out-of-range
// fallback (the `jl`/`cmp 5` fork at `0x52e8fc` — Life Tap's and Bloodrage's −2 → "N Health";
// B192, 1074). Rage displays wire÷10 (the `0x6e7130` per-type divisor), applied to the flat
// and per-second values alike; a `manaPerSecond` column composes the `_PER_TIME` string
// ("11 Health, plus 5 per sec" — Health Funnel), including the cost-0 case. Zero cost with
// zero per-second leaves the cell EMPTY. The ref never prints a percentage — a pct-only cost
// resolves to its flat number like any other, which is why the "% of base mana" line this
// replaces was unfaithful (B152). No store (a DBC-only view) degrades to the flat cost.
// HAPPINESS_COST has no GlobalStrings entry and no 5875 player spell reaches powerType 4;
// the unit word is a dead arm kept for the array's shape.
// The cost cell (`0x52e8ad`): the resolved `power_cost`, named by power type (keys at
// `0x85416c`) and Health for a type outside 0..=4 (`0x52e8fc`: Bloodrage's -2), never a
// percentage. Rage shows wire ÷ 10, a per-second column adds `_PER_TIME`, and no cost at all
// leaves the cell empty.
let resolved_cost = vctx.store.map_or(d.mana_cost, |s| {
crate::spell::usable::power_cost(d, s, vctx.spell_mods)
});
let cost = {
// The one `0x6e7130` table, not a local `if power_type == 1` — decision 2117 found three
// hand-rolled copies of it and one of them had been applied at a single site out of four.
// The shared per-type divisor (`0x6e7130`), never a local `power_type == 1` test.
let div = benilla_protocol::messages::power_display_scale(d.power_type);
let unit = match d.power_type {
0 => "Mana",
@@ -151,20 +111,9 @@ fn spell_tooltip_view(
Some(format!("{} {unit}", resolved_cost / div))
}
};
// The range cell: two attribute gates, then `GetMinMaxRange 0x6e3480` with **`target = NULL`**
// (`0x52e9c2`), then a third gate on the resolved `max <= 0`.
//
// **There is no melee wording anywhere in the client** — `SPELL_RANGE_AREA` is not in
// `WoW.exe` at all, and the melee family is not a display name either: a melee spell is one
// whose `SpellRange` row sets flags bit 0 (the single shipped such row is id 2 "Combat
// Range", 813 spells), and the resolver hands back `max(reach + casterReach + 1.3333334,
// 5.0)` — which then prints through the SAME `SPELL_RANGE` + `"%d"` path as every other
// range, normally **"5 yd range"** (decision 2080 named this cell; it printed the invented
// "Melee Range" until it was converted).
//
// The tooltip passes no target, but that does NOT make both reaches the caster's: the melee
// arm resolves the caster's own `attack_target_guid` and uses that unit's reach, so the cell
// reads wider while you are auto-attacking something big.
// The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then
// `max <= 0`. There is no melee wording: the melee row (flags bit 0, row 2 only) resolves to
// `max(reach + casterReach + 1.3333334, 5.0)` and prints like any other, usually "5 yd range".
let range = (!d.tooltip_omits_range_line())
.then(|| {
let (min, max) = benilla_formats::min_max_range(
@@ -176,11 +125,8 @@ fn spell_tooltip_view(
if max <= 0.0 {
return None;
}
// `SPELL_RANGE = "%s yd range"` — and its hole is a **string**, which is what makes
// the law's pair fork (`0x854fb4`'s `"%d-%d"`, Charge: 8-25) expressible at all: the
// number cell is composed by a nested `SStrPrintf` first and handed over whole. Both
// holes are `fistp` conversions — round-to-nearest, never truncation (the melee sum
// is the only value that is ever fractional).
// `SPELL_RANGE`'s hole is a string: the number is composed first, `"%d"` or `"%d-%d"`
// (`0x854fb4`, Charge's 8-25), each a `fistp` conversion that rounds to nearest.
let yd = |v: f32| f64::from(v).round_ties_even() as i64;
let yards = if min > 0.0 {
format!("{}-{}", yd(min), yd(max))
@@ -190,14 +136,9 @@ fn spell_tooltip_view(
keyed(vctx.get, "SPELL_RANGE", &[Arg::S(&yards)])
})
.flatten();
// The cast line gate `0x52eb15` — wider than the spellbook's `passive`: a TRADE_SKILL or ATTACK
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]). The arm order is
// the byte ladder `0x52eb45-0x52ec90` (1074): a positive time prints sec/min at the 60 s
// threshold; a NEGATIVE SpellCastTimes base is the "Instant cast" sentinel (`52ebce`); at
// zero — next-swing (attr 0x404) → "Next melee", auto-shot (attr 0x2) → "Attack speed",
// channeled (attrEx 0x44) → "Channeled", else the mana fork: "Instant cast" iff
// powerType==0 AND the resolved cost is nonzero (`52ec4b` re-reads `GetPowerCost`'s edi) —
// every other type reads bare "Instant" whatever it costs (Sinister Strike, Life Tap).
// The cast line: gate `0x52eb15` also omits it for a TRADE_SKILL or ATTACK `Effect[0]`; then
// the ladder `0x52eb45`-`0x52ec90`, where a negative base is "Instant cast" (`0x52ebce`) and a
// zero one is "Instant cast" only for a mana spell with a cost (`0x52ec4b`).
let cast_time = if d.tooltip_omits_cast_line() {
None
} else {
@@ -206,9 +147,7 @@ fn spell_tooltip_view(
.get(d.casting_time_index)
.map(|c| c.base_ms as i32)
.unwrap_or(0);
// Each arm is a key. The two timed ones are `%.3g` templates — significant digits, not
// decimals — so the seconds are handed over as a real and the shipped string decides how
// it reads (decision 2080); this used to round to one decimal on our side.
// `%.3g` templates: the seconds go over as a real.
if base > 0 {
let (key, v) = if base >= 60_000 {
("SPELL_CAST_TIME_MIN", f64::from(base) / 60_000.0)
@@ -228,15 +167,12 @@ fn spell_tooltip_view(
} else if d.power_type == 0 && resolved_cost > 0 {
"SPELL_CAST_TIME_INSTANT"
} else {
// "Instant" without the "cast" — its own key, and the reason the mana fork above
// exists at all.
"SPELL_CAST_TIME_INSTANT_NO_MANA"
};
keyed(vctx.get, key, &[])
}
};
// The cooldown cell reads BOTH recovery columns (`max([+0x4c],[+0x50])>0` at `0x52eada` —
// Charge's 15 s is CategoryRecoveryTime; its RecoveryTime is 0).
// The larger recovery column (`0x52eada`): Charge's 15 s is its category recovery.
let recovery_ms = d.recovery_ms.max(d.category_recovery_ms);
let cooldown = (recovery_ms > 0)
.then(|| {
@@ -249,26 +185,8 @@ fn spell_tooltip_view(
keyed(vctx.get, key, &[Arg::F(v)])
})
.flatten();
// The required-form line: the Stances mask's form names off
// SpellShapeshiftForm.dbc, joined; bit b = form id b+1. Met against the CURRENT form.
//
// `Stances != 0` is NOT the gate on its own — the column is overloaded. When `AttributesEx2`
// bit 19 is set the mask is *permissive* ("may ALSO be cast in these forms"), which is how
// 5875 encodes Shadowform-castable priest spells, Spirit-of-Redemption-castable heals and
// Moonkin-castable balance spells; reading those as requirements put a red "Requires
// Shadowform" on Inner Fire and "Requires Spirit of Redemption" on Flash Heal (1483).
//
// The block is `[0x52f10a, 0x52f2ae)`: `52f115` reads AttributesEx2,
// ands `0x80000`, and jumps *over the whole name loop* — the loop is the only writer of the
// "nothing appended" flag `[ebp-8]`, so the AddLine is skipped entirely. The line is
// **suppressed, not recoloured**. Three earned negatives: StancesNot `+0x30` is
// never read here, `Attributes` bit 16 is never tested, and `0x612480` is never called — the
// builder duplicates the gate inline rather than sharing the usable walk's helper, which is
// why the two could drift apart in the first place.
//
// Two ref behaviours we knowingly don't model, both unreachable for us: the composed text is
// capped at 0x80 bytes (our longest join is "Battle Stance, Berserker Stance"), and a *met*
// requirement is dropped in the COMPACT tooltip — benilla has no compact spell tooltip.
// The required-form line (`0x52f10a`-`0x52f2ae`). With `AttributesEx2` bit 19 set the mask is
// permissive (Inner Fire in Shadowform), and the line is skipped (`0x52f115`).
let requires_form = (d.stances != 0 && !d.form_mask_is_permissive())
.then(|| {
let names: Vec<&str> = (0..32u32)
@@ -288,12 +206,8 @@ fn spell_tooltip_view(
})
.flatten();
let form_met = form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0;
// The equipped-item-class half of the requirement lines, `[0x52eea7, 0x52f10a)` — "Requires
// Wands" on the wand Shoot, and "Requires Melee Weapon" on Parry. A mask with several bits set
// is NOT a reason to print nothing (what we used to do): the reference names the whole mask
// through ItemSubClassMask.dbc first, and only falls back to comma-joining the individual
// subclasses; that lookup lives in the catalog. Its three entry gates, all of which skip the
// line: Targets bit 0x10 set, class < 0, or an empty mask.
// The required-item line (`0x52eea7`-`0x52f10a`): a mask is named whole by
// `ItemSubClassMask.dbc` before its subclasses are joined; an empty mask or class < 0 skips it.
let requires_item = (d.targets & TARGET_ITEM == 0 && d.equipped_item_class >= 0)
.then(|| {
vctx.sub_classes?
@@ -301,17 +215,12 @@ fn spell_tooltip_view(
})
.flatten()
.and_then(|name| keyed(vctx.get, "SPELL_EQUIPPED_ITEM", &[Arg::S(&name)]));
// The chance-to-X line (`[0x52f5b1, 0x52f697)`): `Effect[0]` picks which of the player's four
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
// spell must be passive. The percentages are already percents on the wire.
let chance = chance_line(d, vctx.store);
let item_met = vctx.store.is_none_or(|s| {
crate::spell::usable::equipped_item_fits(d, s, vctx.objects, vctx.items, vctx.commands)
});
// Reagents (`SPELL_REAGENTS 0x854e54`): the named slots, `count > 1` suffixed, a slot the
// player is short of wrapped in the builder's inline red. A reagent whose item template hasn't
// streamed yet is simply absent from this snapshot — `feed_spell_tooltips` re-pushes when it
// lands, which is our shape of the ref's own query-then-redisplay callback.
// Reagents (`SPELL_REAGENTS`, `0x854e54`), red when short; one whose template has not
// streamed is absent until `feed_spell_tooltips` re-pushes on its arrival.
let reagents = {
let mut parts: Vec<String> = Vec::new();
for (entry, count) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
@@ -341,8 +250,7 @@ fn spell_tooltip_view(
Some(benilla_ui::script::SpellTooltipView {
name: d.name.clone(),
rank: d.rank.clone(),
// The aura variant's right column (`[0x52f8e5, 0x52f911)`) — gated inside the catalog by
// SpellDispelType.dbc's own `[+0x28]`, so Stealth-class auras hand back None.
// The aura variant's right column (`0x52f8e5`), gated on `SpellDispelType.dbc` `[+0x28]`.
dispel_type: spells.catalog.dispel_name(d).map(str::to_string),
cost,
range,
@@ -367,23 +275,17 @@ fn spell_tooltip_view(
})
}
/// `Targets` bit `0x10` — set ⇒ the equipped-item requirement line is skipped whatever the class
/// and mask say (the line's first gate, `0x52eeaa`; the `TARGET_FLAG_ITEM` name is inferred).
/// `Targets` bit `0x10` (name inferred): set, the required-item line is skipped (`0x52eeaa`).
const TARGET_ITEM: u32 = 0x10;
/// `SPELL_EFFECT_DODGE` / `_PARRY` / `_BLOCK` / `_ATTACK` — the four `Effect[0]` values that select
/// a chance-to-X line (the jump table `0x52f7c4`).
/// The four `Effect[0]` values that select a chance-to-X line (jump table `0x52f7c4`).
const EFFECT_DODGE: u32 = 20;
const EFFECT_PARRY: u32 = 22;
const EFFECT_BLOCK: u32 = 23;
const EFFECT_ATTACK: u32 = 78;
/// The chance-to-X line (`[0x52f5b1, 0x52f697)`) — `None` when the spell names none of the four
/// effects, when the gate rejects it, or before the player's descriptor has streamed.
///
/// The predicate is the reference's, and its asymmetry is the point: **ATTACK bypasses the passive
/// gate** while dodge/parry/block require it. That is why Attack — whose `Attributes` carry `0x10`,
/// not the passive `0x40` — still shows a crit line, which is exactly the missing line reported.
/// The chance-to-X line (`0x52f5b1`-`0x52f697`): ATTACK skips the passive gate that dodge, parry
/// and block need. The wire's values are already percents.
fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -> Option<String> {
let (label, percentage) = match d.effects[0] {
EFFECT_ATTACK => ("crit", store?.0.player_crit_percentage()?),
@@ -392,41 +294,31 @@ fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -
EFFECT_BLOCK if d.passive => ("block", store?.0.player_block_percentage()?),
_ => return None,
};
// The reference's `%.2f%%` lives in its GlobalStrings, not the binary — this is the same shape.
// The enUS text of the reference's `CHANCE_TO_*` keys (`%.2f%% chance to crit`).
Some(format!("{percentage:.2}% chance to {label}"))
}
/// The push loop's change detectors — everything a view SNAPSHOTS at build time. A change to any
/// of them means every pushed view is stale, so the loop re-pushes the lot (the reference simply
/// re-runs its builder on every hover, so a stale snapshot is a benilla-only failure mode).
/// What a pushed view snapshots at build time; a change to any of it re-pushes every view. The
/// reference rebuilds on every hover and keeps no such snapshot.
#[derive(Default)]
struct SpellFeedMemory {
/// Spell ids whose view has been pushed at least once.
pushed: std::collections::HashSet<u32>,
/// The bind point `$z` substitutes against.
/// The bind point `$z` names.
home: Option<String>,
/// The current shapeshift form (the required-form line's white/red, `0x52f1e3`).
/// The form the required-form line's colour follows (`0x52f1e3`).
form: Option<u8>,
/// The 19 worn-slot guids (the equipped-item line's white/red, `0x5f0c50`).
/// The 19 worn-slot guids the required-item line's colour follows (`0x5f0c50`).
worn: Option<[u64; 19]>,
/// The player's block/dodge/parry/crit percentages, as raw bit patterns so the diff needs no
/// float comparison (the chance-to-X line's printed value, `0x52f5b1` — it moves with gear,
/// buffs and talents).
/// The block, dodge, parry and crit percentages as bit patterns (`0x52f5b1`).
avoidance: Option<[u32; 4]>,
/// The two reaches the melee range cell reads, as raw bit patterns — the caster's own
/// (which moves with scale and shapeshift) and its auto-attack target's (which changes with
/// the mob).
/// The caster's and its auto-attack target's reach as bit patterns.
combat_reach: Option<(Option<u32>, Option<u32>)>,
/// Per reagent entry currently on show: `(owned count, item name resolved)` — the reagent
/// line's inline red (`0x854120`) plus the ask-once template landing.
/// Per reagent on show, `(owned count, name resolved)` (the inline red, `0x854120`).
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
}
/// The spell-tooltip store's push half: every spell the UI can HOVER is owed its view at
/// arrival — the known book (spellbook/bar), the class's talent rank spells (`SetTalent`'s
/// display + next-rank reads), and the live aura spells (`SetPlayerBuff`) — so a first hover
/// never misses, exactly like the reference's all-local reads. The renderers' recorded asks
/// (the odd id outside those sets) answer through the same build as the fallback.
/// Push a view for every spell the UI can hover (the book, the class's talent ranks, the auras)
/// before it is hovered, as the reference reads them all locally; an ask for any other id too.
fn feed_spell_tooltips(
script: Option<NonSendMut<UiScript>>,
actions: Option<Res<PlayerActions>>,
@@ -436,17 +328,13 @@ fn feed_spell_tooltips(
selection: Res<crate::target::Selection>,
stores: Query<&ObjectStore>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// Who the player is auto-attacking, if anyone — the melee range cell's second reach.
// The auto-attack target, the melee range cell's second reach.
engaged_q: Query<&crate::creature_anim::Engaged, With<SelfPlayer>>,
// The object lookup (2334) — the guid index the engaged-target reach resolves through,
// plus the bag walk each watched reagent's carried count takes.
objects: Objects,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
// One tuple param (Bevy's 16-SystemParam ceiling): the two lookups the view builder reads
// straight through — the item sub-class names for the required-item line, and the talent
// spell-modifier tables the cost cell resolves through.
// One tuple param, under Bevy's 16-param ceiling.
lookups: (
Option<Res<crate::ui_items::ItemSubClasses>>,
Res<crate::spell::SpellModifiers>,
@@ -472,9 +360,7 @@ fn feed_spell_tooltips(
.filter(|s| !memory.pushed.contains(s)),
);
}
// The talent window's hoverables: every rank spell of the class's pages (a talent tooltip
// reads rank max(1, current) + the next rank — pushing ALL ranks stays correct as ranks
// are learned, and the whole class set is ~250 ids once).
// Every rank of the class's talents: a talent tooltip reads the current and the next rank.
if let (Some(talents), Ok(store)) = (talents.as_deref(), self_q.single()) {
let race = store.0.unit_race().unwrap_or(0);
let class = store.0.unit_class().unwrap_or(0);
@@ -489,14 +375,10 @@ fn feed_spell_tooltips(
}
}
}
// The buff bar's hoverables: the live aura spells, at arrival.
if let Some(auras) = auras.as_deref() {
wanted.extend(auras.spell_ids().filter(|s| !memory.pushed.contains(s)));
}
// The minimap tracking icon's hover (SetTrackingSpell): the tracking aura never enters the
// display cache above (the rebuild's tracking-effect exclusion, `ui_aura`), so pre-feed from
// the player's raw aura array — covers it at arrival, at worst a few extra views for
// display-filtered auras nothing hovers.
// `SetTrackingSpell`: the display cache leaves the tracking aura out, so read the raw auras.
if let Ok(store) = self_q.single() {
wanted.extend(
store
@@ -506,8 +388,6 @@ fn feed_spell_tooltips(
.filter(|s| !memory.pushed.contains(s)),
);
}
// The target frame's aura rows (SetUnitBuff/SetUnitDebuff): the target's live aura spells,
// at selection/arrival — same first-hover guarantee as the buff bar's.
if let Some(store) = selection.target.and_then(|e| stores.get(e).ok()) {
wanted.extend(
store
@@ -522,13 +402,12 @@ fn feed_spell_tooltips(
.and_then(|b| b.0)
.and_then(|id| area_names.as_deref()?.0.resolve(id as i32))
.map(str::to_string);
// A bind-point change re-substitutes every pushed view ($z — Astral Recall's shape).
// A bind-point change re-substitutes every view's `$z` (Astral Recall).
if memory.home != home_area {
memory.home = home_area.clone();
wanted.extend(memory.pushed.drain());
}
// A stance/form change re-pushes too: the required-form line's white/red tracks the CURRENT
// form (`0x52f1e3`), and the views are static snapshots until re-pushed.
// So does a form change: the required-form line's colour follows the form (`0x52f1e3`).
let form = self_q
.single()
.map(|s| s.0.unit_shapeshift_form())
@@ -538,19 +417,13 @@ fn feed_spell_tooltips(
wanted.extend(memory.pushed.drain());
}
let self_store = self_q.single().ok();
// …and so do the two BAG-dependent halves, which the views likewise snapshot: the worn set
// (the item line, `0x5f0c50`, flips white/red on a weapon swap) and the owned counts of the
// reagents currently on show (the inline red `0x854120`, plus the item names themselves landing
// from the ask-once template cache — the ref's query-then-redisplay callback in our shape).
// Both signatures are tiny: 19 slot guids, and one count per DISTINCT reagent in play.
// So do the worn set and, below, the reagents on show.
let worn =
self_store.map(|s| std::array::from_fn(|i| s.0.player_inv_slot(i as u8).unwrap_or(0)));
if memory.worn != worn {
memory.worn = worn;
wanted.extend(memory.pushed.drain());
}
// …and so does the chance-to-X line's percentage (`0x52f5b1`), which the views snapshot the
// same way: a weapon swap, a buff or a talent moves it.
let avoidance = self_store.map(|s| {
[
s.0.player_block_percentage(),
@@ -564,8 +437,6 @@ fn feed_spell_tooltips(
memory.avoidance = avoidance;
wanted.extend(memory.pushed.drain());
}
// …and so does the range cell's melee arm, whose inputs are the caster's own reach and the
// reach of whatever it is auto-attacking (`0x6e3480` resolves the latter itself).
let attack_target_reach = engaged_q
.single()
.ok()
@@ -579,11 +450,7 @@ fn feed_spell_tooltips(
memory.combat_reach = Some(reaches);
wanted.extend(memory.pushed.drain());
}
// …and so does the cost cell, whose resolved number now goes through the talent
// spell-modifier tables: a respec changes what every affected spell costs, and this feed is
// the one consumer of `power_cost` that memoizes its build (the action bar recomputes every
// frame). The reference has no cache here at all — it reads the tables live at every call
// site — so this is what keeps the cell honest about the same change (`crate::spell_mods`).
// So does a spell-modifier change: the cost cell resolves through the talent tables.
if spell_mods.is_changed() {
wanted.extend(memory.pushed.drain());
}
@@ -602,9 +469,7 @@ fn feed_spell_tooltips(
memory.reagents = reagent_state;
wanted.extend(memory.pushed.drain());
}
// The string table is read while the views are BUILT and the store is written when they are
// PUSHED, so the two passes are split — one borrow of the VM cannot be both, and the build's
// borrow ends with this block.
// Build, then push: the build's borrow of the VM's strings ends before the store is written.
let mut built: Vec<(u32, benilla_ui::script::SpellTooltipView)> = Vec::new();
{
let get = |key: &str| benilla_ui::strings::global(script.lua(), key);
@@ -625,8 +490,7 @@ fn feed_spell_tooltips(
};
for id in wanted {
if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) {
// Register this spell's reagents in the watch set, seeded with the state the view was
// just built against — so the next recompute re-pushes on a REAL change only.
// Watch this spell's reagents, seeded with the state the view was built against.
if let Some(d) = spells.catalog.get(id) {
for (entry, _) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
if let std::collections::btree_map::Entry::Vacant(slot) =
@@ -650,38 +514,28 @@ fn feed_spell_tooltips(
}
}
/// What the tooltip was last driven for — the change detector (the byte law rebuilds once per
/// hover-target change).
/// What the world tooltip was last built for; the reference rebuilds once per hover-target change.
#[derive(Default, PartialEq, Clone, Copy)]
enum LastHover {
#[default]
None,
Unit(u64),
Go(u64),
/// A hovered **corpse object** — its own plate, keyed on the corpse's guid (1729).
/// A hovered corpse object, keyed on the corpse's guid.
Corpse(u64),
}
/// The snapshot fields the unit tooltip's LINES read (everything except the bar's
/// health/power) — the rebuild key: a change here means the rendered lines are stale.
/// The world-hover driver's own memory, bundled — which plate it put up, the line-affecting fields
/// that plate was built from, and the headless probe's say-once-on-change trace line (2255).
///
/// One [`SystemParam`](bevy::ecs::system::SystemParam) rather than three parameters for the reason
/// [`crate::target::hover::GoPickSet`] is one: [`drive_mouseover_tooltip`] sits at Bevy's 16-param
/// function-system ceiling. A bare tuple did the same job and tripped `clippy::type_complexity`,
/// which is the lint asking for exactly this.
/// The world-hover driver's memory, one `SystemParam` because the driver is at Bevy's 16-param
/// ceiling.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct HoverMemo<'s> {
/// Which world plate is currently up — the driver's "do not rebuild this every frame" memo.
/// **Faithful**: on an unchanged mouseover the reference makes no call at all (`0x482090`
/// returns at `0x4820b5`), so a plate Lua takes mid-hover staying gone is reference behaviour
/// and not a bug to fix here. See 2255.
/// Which world plate is up. On an unchanged mouseover the reference makes no call (`0x482090`
/// returns at `0x4820b5`), so a plate Lua takes mid-hover stays gone.
last: Local<'s, crate::ui_script::VmMemo<LastHover>>,
/// The line-affecting fields the current unit plate was built from, so a late-arriving name or
/// creature-info rebuilds it under the same hover.
last_lines: Local<'s, crate::ui_script::VmMemo<Option<UnitState>>>,
/// The headless probe's last trace line, so a stationary probe says it once (2255).
/// The hover probe's last trace line, so a stationary probe logs it once.
trace: Local<'s, String>,
}
@@ -695,23 +549,11 @@ fn lines_view(s: &UnitState) -> UnitState {
}
}
/// The **"Locked" line's colour** (`0x52ab03`-`0x52ab43`, decision 0770).
///
/// The builder seats red `0xc0d3a8` as the default *before* it calls the resolver, then re-colours
/// on the answer. Every non-`Unmet` answer lands on the same green `0xc0d420`, by two separate
/// branches that agree: an opener was found with **no item** and no matched spell (`0x52ab22 je`
/// — the no-requirement case), or an opener was found **with** an item, i.e. a KEY (`0x52ab29
/// jne`). Only a lock nothing can open keeps the red.
///
/// `None` = a flag-locked object with no `Lock.dbc` row at all, which is the reference's
/// no-requirement arm and therefore green — the flag alone is not a refusal.
///
/// **Not modelled:** the third branch, where a *skill* opener satisfied the lock and the colour
/// becomes the difficulty ramp `0x529fa0` (grey `0xc0cf50` / green `0xc0d420` / yellow `0xc0cf18`
/// / orange `0xc0d3a4` / red `0xc0d3a8`, banded at the requirement +0/25/50/100 — the trade-skill
/// ladder). Green is that ramp's comfortable rung, so a well-skilled opener already reads right
/// and only a marginal one reads too green. Pinned in decision 0770; deliberately left for its own
/// change, since it needs the resolver to hand back the margin it currently discards.
/// The "Locked" line's colour (`0x52ab03`-`0x52ab43`): red `0xc0d3a8` unless the resolver finds an
/// opener, then green `0xc0d420`, for a key (`0x52ab29`) or no requirement (`0x52ab22`); `None`, a
/// flag-locked object with no `Lock.dbc` row, is no requirement. A skill opener takes the
/// reference's difficulty ramp `0x529fa0` (grey, green, yellow, orange, red at +0/25/50/100);
/// benilla shows its green, since the resolver discards the margin.
fn locked_line_tint(outcome: Option<crate::target::lock::LockOutcome>) -> TooltipTint {
match outcome {
Some(crate::target::lock::LockOutcome::Unmet) => TooltipTint::Red,
@@ -723,29 +565,24 @@ fn drive_mouseover_tooltip(
script: Option<NonSendMut<UiScript>>,
hovered: Res<Hovered>,
hovered_go: Res<HoveredObject>,
// The cursor-arm seat of the GO anchor fork (decision 0766).
// Seats the cursor-anchored GameObject plate.
window: Query<&Window, With<PrimaryWindow>>,
stores: Query<&ObjectStore>,
// The stored GAMEOBJECT_STATE the lock lines' Action gate reads (decision 0752).
// The stored GAMEOBJECT_STATE the lock lines' Action gate reads.
anims: Query<&crate::go_anim::GoAnim>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
// The standing pair as one param — see [`crate::target::ReactionInputs`]. Bundled here and
// not elsewhere because this system sits on Bevy's tuple limit.
rx: crate::target::ReactionInputs,
// The lock chain's own data set, shared verbatim with the click router (`target::lock`) so the
// hover and the click can never disagree about whether a lock is satisfiable — the same reason
// `usable` and the click share one resolver (0752). Carries the go-template, Lock.dbc and
// item caches this system used to take as three separate params.
// The lock chain's data, shared with the click router (`target::lock`) so hover and click
// agree on whether a lock can be opened.
go_inputs: crate::target::lock::GoLockInputs,
// The known-spell set the resolver's SKILL arm scans.
player_actions: Res<crate::ui_action::PlayerActions>,
// The cursor seat crosses the VM seam (0582/0584): the anchor below is UI units, not px.
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16 — `UnitHasRelicSlot`'s only input. Absent when the client data
// failed to load, in which case no class reads as having a relic slot.
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input; without it no class has a relic slot.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
) {
let Some(mut script) = script else {
@@ -757,7 +594,6 @@ fn drive_mouseover_tooltip(
let self_store = self_q.iter().next();
let chr = classes.as_deref().map(|t| &t.0);
// The hovered UNIT's snapshot (a hovered non-unit resolves no store here).
let unit = hovered.target.zip(hovered.guid).and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let name = names
@@ -780,29 +616,16 @@ fn drive_mouseover_tooltip(
);
Some((guid, s))
});
// The hovered GAMEOBJECT, when it is the nearer pick (the click router's own arbitration).
// Deliberately NOT gated on the highlightable predicate (0558/0559): the mouseover publisher
// `0x492890` dispatches the GO tooltip builder `0x52aa20`
// by object KIND on both branches; highlightable is never read on the tooltip path (it gates
// the cursor and the click only). So a GENERIC(5) signpost, a pre-quest INTERACT_COND chest,
// and an IN_USE object all show the gold name plate while showing NO interact cursor — 0466's
// "no cursor AND no tooltip" coupling was the regression. Transports never reach here — they
// are excluded from the pick set itself (0466's correct half).
// "Nothing else was picked, or the GameObject is the nearer pick." **A hovered corpse counts
// as something else picked** (decision 1723) — it has no tooltip of its own yet, and the
// `unit.is_none()` short-circuit would otherwise hand a farther GameObject the gold plate the
// moment the ray landed on a body instead of a unit.
// The hovered GameObject, when nothing else was picked or it is nearer. Not gated on
// highlightability: the publisher `0x492890` sends every object to the builder by kind, so a
// signpost or an in-use object shows its plate with no interact cursor.
let go = hovered_go.target.zip(hovered_go.guid).filter(|_| {
(unit.is_none() && hovered.corpse.is_none()) || go_is_nearest(&hovered, &hovered_go)
});
if let Some((guid, state)) = unit.filter(|_| go.is_none()) {
// Push first (the engine's builder + the recolor's UnitReaction read the token), then
// rebuild on a hover-target change OR when a LINE-affecting field changes under the
// same hover — the late-arriving name/creature-info case: the first render often
// precedes the SMSG_NAME_QUERY/CREATURE_QUERY answers, and a once-per-guid render
// would keep the stale (even empty) lines for the whole hover. Health/power stay OUT
// of the key: the byte law's watcher drives the BAR without a line rebuild.
// Push first: the builder reads the token. Rebuild on a new target or a late name or
// creature answer; health and power stay out of the key, since the watcher drives the bar.
let key = lines_view(&state);
script.set_unit("mouseover", Some(state));
if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) {
@@ -812,14 +635,8 @@ fn drive_mouseover_tooltip(
}
return;
}
// The **corpse plate** — "Corpse of <owner>" (the reference's own builder `0x52aef0`). Ahead
// of the GameObject arm because the corpse is the nearer pick whenever `go` came back empty.
//
// It is a *name plate and nothing else*: no health bar, no reaction recolour, no level or
// creature-type line — a corpse is not a unit, and the publisher deliberately fires **no
// event** for it, so it never becomes the Lua `mouseover` unit token either (`UnitName
// ("mouseover")` on a body is nil in the reference, and stays nil here). Corner-seated like a
// unit's, not cursor-seated like a signpost's.
// The corpse plate, "Corpse of <owner>" (`0x52aef0`): a name and nothing else, corner-seated.
// The publisher fires no event for a corpse, so `UnitName("mouseover")` on one stays nil.
if let Some((entity, guid)) = hovered
.corpse
.zip(hovered.corpse_guid)
@@ -828,7 +645,7 @@ fn drive_mouseover_tooltip(
let Some(store) = stores.get(entity).ok() else {
return;
};
// A bone pile with nothing to take publishes no mouseover at all — no plate, no brighten.
// A bone pile with nothing to take publishes no mouseover: no plate, no brighten.
if !crate::target::corpse_mouseover_eligible(store) {
if !matches!(*last, LastHover::None) {
script.world_tooltip_fade();
@@ -837,19 +654,16 @@ fn drive_mouseover_tooltip(
return;
}
if *last == LastHover::Corpse(guid) {
return; // already showing; nothing re-seats a corner plate
return; // already up; a corner plate never re-seats
}
// The owner's name is the ask-once cache like any other player name — a corpse carries
// `CORPSE_FIELD_OWNER`, never a name. Until the answer lands there is no plate to show
// (`last` stays `None`, so it appears the moment the name arrives).
// A corpse carries `CORPSE_FIELD_OWNER`, not a name; the plate waits for the name query.
let Some(owner) = store.0.corpse_owner() else {
return;
};
let Some(name) = names.resolve(owner, &commands).map(str::to_string) else {
return;
};
// `CORPSE_TOOLTIP = "Corpse of %s"` — the builder's own key (`0x52aef0`). No string, no
// plate, which is the reference's data-suppression face.
// `CORPSE_TOOLTIP`, the builder's key; no string, no plate.
let Some(plate) = keyed(
&|key: &str| benilla_ui::strings::global(script.lua(), key),
"CORPSE_TOOLTIP",
@@ -862,45 +676,21 @@ fn drive_mouseover_tooltip(
return;
}
if let Some((entity, guid)) = go {
// Which arm of the anchor fork this object takes (decision 0766). The reference asks the
// object's own `[obj->vtbl+0x5c]`; what selects it is not pinned, so we key on the one
// distinction the director's two reference observations agree on — a **GENERIC(5)**
// signpost follows the cursor, an interactable GameObject sits in the corner.
//
// 0766 keyed this on "is it GENERIC(5)" and said plainly that it was a proxy: the real
// client asks the object's own `[vtbl+0x5c]`, and what selects it was not then pinned.
// **It is pinned now, and it is narrower** (decision 2259): `[vtbl+0x5c]` is `0x5f8630`,
// whose body is `template.data[0x621b00(type, semantic 0x13)] != 0`, and key `0x13`
// resolves for exactly ONE of the 31 GO types — GENERIC(5), at `data[0]`. Every other type
// gets `-1` back and `0x5f8150`'s unsigned bound turns that into FALSE.
//
// So the cursor arm is **GENERIC with `data[0]` set**, and a GENERIC with it clear is
// corner-seated like everything else. That also settles 0766's named divergence: the three
// always-eligible types (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25) carry no key
// `0x13`, so all three are corner-seated — 0766's "GENERIC" reading wins over its "not
// interactable" one.
//
// **`data[0]` is not `data[1]`.** The neighbouring slot is the mouseover-ELIGIBILITY column
// (0762, semantic `0x12`, `0x5f4830`), and the two answer different questions: `data[1]`
// says whether the object is hoverable at all, `data[0]` only says *where its plate sits*.
// 342 of the 447 type-5 entries in the reference's own `gameobjectcache.wdb` carry both;
// the objects that differ are hoverable and corner-seated, not silent.
// The plate follows the cursor iff `[vtbl+0x5c]` (`0x5f8630`) finds
// `data[0x621b00(type, 0x13)]` set, and semantic `0x13` exists only for GENERIC(5), at
// `data[0]`. `data[1]` (`0x5f4830`) is whether it can be hovered, not where it sits.
let cursor_seated = stores.get(entity).map(|s| s.0.gameobject_type_id())
== Ok(GO_TYPE_GENERIC)
&& go_inputs
.templates
.get(guid)
.is_some_and(|t| t.floating_tooltip);
// Window px → the VM's y-up 768-virtual units (÷s, the input seam's own conversion) —
// the anchor this point seats is resolved in UI units, so a raw-px point lands the
// plate (s−1)× the cursor's distance from the bottom-left corner away from it.
// Window px to the VM's y-up UI units, as the input seam converts.
let cursor_ui = cursor_seated
.then(|| {
window.iter().next().and_then(|w| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
// The headless probe's aim stands in for a cursor the window does not have
// (2250), so an automated run can carry a GENERIC plate — which is
// cursor-seated — all the way to the screen. A person's pointer always wins.
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
w.cursor_position()
.or_else(crate::target::hover_probe_point)
.map(|c| (c.x / s, (w.height() - c.y) / s))
@@ -925,7 +715,7 @@ fn drive_mouseover_tooltip(
return;
}
if cursor_seated && cursor_ui.is_none() {
return; // cursor off-window: nothing to seat the pointer-anchored plate against
return; // cursor off-window: nothing to seat the plate against
}
if crate::target::hover_probe_armed() {
info!(
@@ -935,29 +725,14 @@ fn drive_mouseover_tooltip(
);
}
let Some(template) = go_inputs.templates.get(guid).cloned() else {
// Template in flight: ask once and retry next frame (`last` stays, so the show
// fires the moment the name lands).
// Template in flight: ask once, and show it when it lands.
go_inputs.templates.request(guid, &commands);
return;
};
// The lock lines, transcribed from the builder `0x52aa20` (decision 0756). Two blocks, in
// the binary's order, and both are narrower than the sweep we used to print:
//
// A) **"Locked"** — emitted iff `GAMEOBJECT_FLAGS & GO_FLAG_LOCKED` (`0x52aae5`:
// `shr 1; test dl,1`). That flag gates this line and nothing else.
// B) **ONE requirement line, from Lock.dbc SLOT 0 ONLY** (`[lockRow+4]` / `[lockRow+0x24]`
// — the builder never walks the other seven), and only when slot 0 passes the same
// per-slot Action gate the resolver uses (`0x52ab7e` → `0x5f81d0`, decision 0752):
// · KEY → **white** `LOCKED_WITH_ITEM` "Requires <item>" (`0x854988`; `0x52acd9`
// pushes `0xc0cf60` = white)
// · SKILL, opener unknown **and** the object is flag-locked → **nothing at all**
// (`0x52abf7: jne done`) — a padlocked door says "Locked" and names its key, never
// its lockpicking rank
// · SKILL, opener unknown, not flagged → **red** "Requires <word>" (the herb node)
//
// That is why the reference shows *"Locked" + "Requires Key to Searing Gorge"* on the
// Searing Gorge door and not the `Requires Lockpicking (225)` line we used to add: the
// Pick Lock requirement is that lock's slot **1**, and the builder never looks past 0.
// The builder's lock lines (`0x52aa20`): "Locked" iff `GO_FLAG_LOCKED` (`0x52aae5`), then
// one line from `Lock.dbc` slot 0 only, if it passes the Action gate (`0x52ab7e`): a key in
// white (`0x52acd9`), an unknown skill in red but only on an unflagged object (`0x52abf7`),
// so a locked door names its key, never its lockpicking.
let go_store = stores.get(entity).ok();
let flags = go_store.map_or(0, |s| s.0.gameobject_flags());
let flag_locked = flags & GO_FLAG_LOCKED != 0;
@@ -970,22 +745,11 @@ fn drive_mouseover_tooltip(
.filter(|_| template.lock_id != 0)
.and_then(|l| l.0.slots(template.lock_id));
let mut lines: Vec<(String, TooltipTint)> = Vec::new();
// The lock lines' string table, taken for the length of the build. Every one of them is a
// key whose enUS value is "Requires %s" and whose identity only the binary settles.
// The lock lines' keys all read "Requires %s" in enUS; only the binary tells them apart.
let go_get = |key: &str| benilla_ui::strings::global(script.lua(), key);
if flag_locked {
// **The "Locked" line is coloured by whether you can actually open it** (director-
// reported: a door you hold the key for read red). The builder sets red `0xc0d3a8` as
// the default (`0x52ab03`), calls the SAME resolver the click uses (`0x52ab14` →
// `0x5f83d0`) and re-colours on its answer (`0x52ab19`-`0x52ab43`):
// · resolver says NO opener -> keep red
// · a KEY item satisfied it -> `0xc0d420` green (spell out-param set AND item
// out-param set: `0x52ab29 jne` takes the green arm)
// · no lock requirement at all-> the same green (`0x52ab22 je`)
// · a SKILL opener satisfied it -> the difficulty ramp `0x529fa0` — NOT modelled;
// see this line's follow-up note in decision 0770. Green is its second rung, so a
// comfortably-skilled opener already reads correctly; a marginal one reads too
// green rather than yellow/orange.
// Coloured by whether the player can open it: the builder asks the resolver the click
// uses (`0x52ab14` → `0x5f83d0`), and `locked_line_tint` maps the answer.
let facts = crate::target::lock::go_facts(go_store.map(|s| (s, state)));
let mut matched = None;
let outcome = slots.map(|slots| {
@@ -1011,28 +775,18 @@ fn drive_mouseover_tooltip(
match slot0.key_type {
benilla_formats::LOCK_KEY_ITEM => {
if let Some(t) = go_inputs.items.template(slot0.index, 0, &commands) {
// `LOCKED_WITH_ITEM`, resolved at `0x52acb8` — one of eleven 1.12 keys
// whose enUS value is exactly "Requires %s", and the only one this arm
// reaches.
// `LOCKED_WITH_ITEM`, at `0x52acb8`.
if let Some(text) = keyed(&go_get, "LOCKED_WITH_ITEM", &[Arg::S(&t.name)]) {
lines.push((text, TooltipTint::White));
}
}
}
// The opener-*known* arm additionally wants the reference's skill-margin colour
// ramp (`0x529fa0`) — now pinned (decision 0770) but not modelled here; what we
// model is the unknown arm, which is what a hovering player almost always is. A
// flagged object stays silent there, exactly as the binary does.
// Every skill lock takes the opener-unknown arm, silent on a flag-locked object;
// the known arm, `LOCKED_WITH_SPELL_KNOWN` in the margin ramp's colour
// (`0x529fa0`), is not built.
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
// **`LOCKED_WITH_SPELL`, not `LOCKED_WITH_SPELL_KNOWN`** — the two are the
// same sentence in enUS and the byte test between them is a pure MEMBERSHIP
// one: `0x5f83d0` writes a non-zero spell id into its first out-param iff the
// player knows *any* spell that opens this LockType, and `0x52abcb` branches
// on that. This is the not-known arm (the flag-clear leg at `0x52ac04`);
// skill *sufficiency* never changes the key, only the colour.
//
// The `%s` is `LockType.dbc`'s own localized `Name` — "Pick Lock", not
// "Lockpicking", which is what a hand-typed table here used to say.
// `LOCKED_WITH_SPELL` (`0x52ac04`), named by `LockType.dbc` ("Pick Lock"); the
// reference takes the known arm iff any known spell opens it (`0x52abcb`).
let word = go_inputs
.lock_types
.as_deref()
@@ -1051,9 +805,7 @@ fn drive_mouseover_tooltip(
return;
}
if !matches!(*last, LastHover::None) {
// Hover lost: arm the fade (the byte law's timestamped fade, never an instant hide).
// The "mouseover" state stays until the next hover overwrites it, so the fading
// lines/bar keep their last content.
// Hover lost: arm the fade. The `mouseover` state stays, so the fading plate keeps it.
script.world_tooltip_fade();
*last = LastHover::None;
}
+50 -106
View File
@@ -1,30 +1,21 @@
//! The spell-view cell tests, against the REAL 5875 data (the module doc's line laws; moved
//! out of `mod.rs` whole at the 1000-line seam — same tests, file module).
//! The spell-view cell tests, against the real 5875 data.
use super::*;
use crate::ui_action::Spells;
/// A view context with no player state — the DBC-only half of the builder (the shape the
/// pre-0616 test used). `sub_classes` is threaded in by the caller when the case needs it.
/// A view context with no player state, the DBC-only half of the builder.
struct TestCtx {
items: Items,
commands: NetCommands,
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
/// The two lookups the builder resolves through, over the **shipped** `GlobalStrings.lua` in
/// a VM this harness owns.
///
/// Every cell this builder composes is a key resolved at runtime (decision 2045), so a test
/// that asserts a rendered cell has to grade it against the player's own table — a stub would
/// pass on wording the client never shows, which is the trap 2052 named when it moved the
/// glue tests onto the loader's own assembly.
/// The builder's two lookups, over the shipped `GlobalStrings.lua`: a stub would pass on
/// wording the client never shows.
get: Box<Getter>,
text: Box<Filler>,
/// Empty tables, which is the un-talented character every cell here is graded as: the cost
/// cell's modifier hop must be the identity when nothing has been sent.
/// Empty: every cell here is graded as an untalented character.
spell_mods: crate::spell::SpellModifiers,
}
/// The two lookup shapes, named so the harness's fields read.
type Getter = dyn Fn(&str) -> Option<String>;
type Filler = dyn Fn(&str, &[i64]) -> Option<String>;
@@ -60,9 +51,6 @@ impl TestCtx {
self.ctx_for(objects, form, sub_classes, None)
}
/// The same context with an auto-attack target engaged — the melee range arm's second
/// reach, which `0x6e3480` resolves out of `[caster+0xc48]` rather than taking as an
/// argument.
fn ctx_engaged<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
@@ -86,8 +74,6 @@ impl TestCtx {
form,
store,
combat_reach: store.map_or(1.5, |s| s.0.unit_combat_reach()),
// The tests drive the melee arm through `combat_reach` alone; the engaged-target
// reach has its own case in `range_cell_on_real_data`.
attack_target_reach: None,
objects,
items: &mut self.items,
@@ -100,25 +86,18 @@ impl TestCtx {
}
}
/// The object index the possession cells resolve through — nothing streamed, which is what
/// every case here is graded against: the worn-item search finds no instance and each reagent's
/// carried count reads 0 (decision 2334). Held beside the [`TestCtx`] because the lookup borrows
/// the world it reads.
/// An object index with nothing streamed: no worn item, and every reagent count 0.
fn no_objects() -> crate::ui_items::TestObjects {
crate::ui_items::TestObjects::new()
}
/// A player descriptor with nothing worn and nothing in the bags — the "owns none of it"
/// pole of both possession tests.
fn empty_player() -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
22u16, 100u32,
)]))
}
/// The full spell-tooltip view off the REAL 5875 data — Fireball rank 1 (133) end to end:
/// the pinned columns (description 138, cast index 18→1500 ms, duration 30), the token
/// engine's byte formulas, and the view's verified cell shapes. Skips without client data.
/// Fireball rank 1 (133) end to end: description 138, cast index 18 (1500 ms), duration 30.
#[test]
fn fireball_view_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
@@ -161,9 +140,8 @@ fn fireball_view_on_real_data() {
v.description
);
// Charge rank 1 (100) — the director's reference shot, end to end: the dual-bound range
// row (SpellRange 95 = {8, 25}), the CATEGORY-column cooldown (recoveryTime 0 /
// categoryRecoveryTime 15000), and the Stances-mask form line (0x10000 → form 17).
// Charge rank 1 (100): range row 95 = {8, 25}, a cooldown only in the category column
// (15000), and the Stances form line (0x10000 is form 17).
let v = spell_tooltip_view(100, &spells, &mut t.ctx(&objects, 0, None)).expect("Charge view");
assert_eq!(v.name, "Charge");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
@@ -180,11 +158,8 @@ fn fireball_view_on_real_data() {
let v = spell_tooltip_view(100, &spells, &mut t.ctx(&objects, 17, None)).expect("Charge view");
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
// 1483 — a PERMISSIVE Stances mask prints no line at all. 5875 overloads the column: with
// `AttributesEx2` bit 19 set the bits say "may ALSO be cast in these forms", so reading them
// as a requirement invented a red "Requires Shadowform" on Inner Fire / Psychic Scream and
// "Requires Spirit of Redemption" on Flash Heal. Bit 27 = form 28, bit 31 = form 32 — the
// names resolve, which is exactly why the bug looked plausible.
// A permissive Stances mask (`AttributesEx2` bit 19) prints no line, though its forms have
// names: bit 27 is form 28, bit 31 form 32.
for (id, name, mask) in [
(588u32, "Inner Fire", 0x0800_0000u32),
(8122, "Psychic Scream", 0x0800_0000),
@@ -202,12 +177,6 @@ fn fireball_view_on_real_data() {
}
}
/// The cost and cast cells' full law on the REAL 5875 data (decision 1074, B192): the health
/// fallback and pct resolution (Bloodrage), the empty Life Tap cell (1.12 carries no cost
/// columns for it — the trade lives in the description), the `_PER_TIME` composite (Health
/// Funnel), the resolved pct-of-base-mana with no percentage line (Judgement — the B152
/// reframe), and the cast ladder's attr arms (Next melee / Attack speed / Channeled) with
/// the mana-keyed Instant fork. Skips without client data.
#[test]
fn cost_and_cast_cells_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
@@ -223,8 +192,8 @@ fn cost_and_cast_cells_on_real_data() {
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A level-60 warrior-shaped store: max health 4000, base mana 1000 (field indices are
// the protocol crate's: health 22, maxhealth 28, level 34, base mana 162).
// A level-60 store: max health 4000, base mana 1000 (fields: health 22, max health 28,
// level 34, base mana 162).
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 3500u32),
(28, 4000),
@@ -232,9 +201,8 @@ fn cost_and_cast_cells_on_real_data() {
(162, 1000),
]));
// Bloodrage (2687): pct-ONLY health cost — 20% of MAX health resolves to a flat number
// through the health fallback; never a percentage line. Instant on a non-mana type reads
// bare "Instant" whatever it costs.
// Bloodrage (2687): 20% of max health, a flat number through the health fallback; bare
// "Instant" on a non-mana type.
let v = spell_tooltip_view(
2687,
&spells,
@@ -244,8 +212,7 @@ fn cost_and_cast_cells_on_real_data() {
assert_eq!(v.cost.as_deref(), Some("800 Health"), "20% of 4000");
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
// Life Tap (1454): the 5875 file carries NO cost columns for any rank — the cell is
// empty, exactly the reference render (the printed health cost is 2.x's change).
// Life Tap (1454): the 5875 data has no cost columns for any rank, so the cell is empty.
let v = spell_tooltip_view(
1454,
&spells,
@@ -259,8 +226,7 @@ fn cost_and_cast_cells_on_real_data() {
"health type, not mana"
);
// Health Funnel (755): the `_PER_TIME` composite in the health lane, and the channeled
// cast cell.
// Health Funnel (755): the `_PER_TIME` form in health, and a channeled cast cell.
let v = spell_tooltip_view(
755,
&spells,
@@ -270,9 +236,8 @@ fn cost_and_cast_cells_on_real_data() {
assert_eq!(v.cost.as_deref(), Some("11 Health, plus 5 per sec"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
// Judgement (20271): pct-of-base-mana resolves to its flat number — the line B152
// reported as "% of base mana" never exists on the reference. DBC-only (no store)
// degrades to the flat cost: none here.
// Judgement (20271): 6% of base mana resolves to a flat number; a DBC-only view has only
// the flat cost, none here.
let v = spell_tooltip_view(
20271,
&spells,
@@ -284,16 +249,14 @@ fn cost_and_cast_cells_on_real_data() {
spell_tooltip_view(20271, &spells, &mut t.ctx(&objects, 0, None)).expect("Judgement view");
assert_eq!(v.cost, None, "a DBC-only view cannot resolve a pct cost");
// Heroic Strike (78): the cost cell keeps its rage (wire 150 ÷ 10) and "Next melee"
// moves to the CAST cell where the ref's ladder puts it.
// Heroic Strike (78): rage is wire 150 ÷ 10, and "Next melee" is the cast cell.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.cost.as_deref(), Some("15 Rage"));
assert_eq!(v.cast_time.as_deref(), Some("Next melee"));
// Throw (2764) and Auto Shot (75): the ranged bit reads "Attack speed" — and for Throw
// the bit is ALONE (`Attributes & 0x2`, not the auto-repeat or-pair). Melee Attack
// (6603) is the cast line's skip at `0x52eb3c`: Effect[0] == ATTACK omits the whole line.
// Throw (2764) and Auto Shot (75) read "Attack speed" off `Attributes & 0x2` alone; Attack
// (6603), `Effect[0]` ATTACK, omits the line (`0x52eb3c`).
let v = spell_tooltip_view(
2764,
&spells,
@@ -312,8 +275,7 @@ fn cost_and_cast_cells_on_real_data() {
.expect("Attack view");
assert_eq!(v.cast_time, None, "ATTACK Effect[0] omits the line");
// Mind Flay (15407): a channeled MANA spell — the cost cell and the channeled cell
// together.
// Mind Flay (15407): a channeled mana spell.
let v = spell_tooltip_view(
15407,
&spells,
@@ -324,11 +286,8 @@ fn cost_and_cast_cells_on_real_data() {
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
}
/// The RANGE cell's whole law (`[0x52e9a2, 0x52ea8c)`) on the REAL 5875 data: the melee family
/// renders through `SPELL_RANGE` off the caster's own combat reach — the
/// invented "Melee Range" decision 2080 named is gone — the authored rows print their own numbers
/// through the same key, and the on-next-swing class and the self-only rows print no cell at all.
/// Skips without client data.
/// The range cell (`0x52e9a2`-`0x52ea8c`): the melee row prints a number off the reaches, an
/// authored row its own, and the on-next-swing and self rows nothing.
#[test]
fn range_cell_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
@@ -347,9 +306,8 @@ fn range_cell_on_real_data() {
// A default-reach player: `UNIT_FIELD_COMBATREACH` (130) unset reads the descriptor's 1.5.
let store = empty_player();
// Sinister Strike (1752) sits on SpellRange row 2 "Combat Range" — the ONE shipped row with
// flags bit 0. 1.5 + 1.5 + 1.3333334 = 4.333 does not clear the 5.0 floor, so the cell reads
// the floor: the melee family's normal render is a NUMBER, not a word.
// Sinister Strike (1752) is on row 2 "Combat Range", the one row with flags bit 0:
// 1.5 + 1.5 + 1.3333334 = 4.333 is under the 5.0 floor.
let d = spells.catalog.get(1752).expect("Sinister Strike 1752");
assert_eq!(d.range_index, 2, "the melee row");
assert!(spells.ranges.get(2).expect("row 2").is_melee());
@@ -361,8 +319,7 @@ fn range_cell_on_real_data() {
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("5 yd range"));
// …and it MOVES with the caster's reach, which is the whole reason the cell needs a store:
// 4.0 + 4.0 + 1.3333334 = 9.333 clears the floor and `fistp` rounds it to 9.
// A 4.0 reach: 4.0 + 4.0 + 1.3333334 = 9.333, rounded to 9.
let big = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
130u16,
4.0f32.to_bits(),
@@ -371,8 +328,7 @@ fn range_cell_on_real_data() {
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("9 yd range"));
// …and the second reach is the AUTO-ATTACK target's, not the caster's doubled: swinging at
// a 4.0-reach mob with a default 1.5 body reads 1.5 + 4.0 + 1.3333334 = 6.833 -> 7.
// The second reach is the auto-attack target's: 1.5 + 4.0 + 1.3333334 = 6.833, rounded to 7.
let v = spell_tooltip_view(
1752,
&spells,
@@ -381,7 +337,7 @@ fn range_cell_on_real_data() {
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("7 yd range"));
// An authored single-number row: Fireball's 0–35.
// An authored row: Fireball's 0-35.
let v = spell_tooltip_view(
133,
&spells,
@@ -390,15 +346,13 @@ fn range_cell_on_real_data() {
.expect("Fireball view");
assert_eq!(v.range.as_deref(), Some("35 yd range"));
// An authored PAIR (the `"%d-%d"` nested fill): Charge's 8–25, unpadded — the tooltip's own
// `GetMinMaxRange` call passes `target = NULL`, so the reach never joins these two.
// An authored pair, `"%d-%d"`: Charge's 8-25, which the reach does not change.
let v = spell_tooltip_view(100, &spells, &mut t.ctx_for(&objects, 0, None, Some(&big)))
.expect("Charge view");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
// The two absences. Heroic Strike (78) carries the on-next-swing pair, which jumps the cell
// before the resolver runs — it does NOT read "Melee Range", and it does not read 5 yd
// either. Bloodrage (2687) sits on the self row, whose resolved max is 0.
// No cell: Heroic Strike (78)'s on-next-swing attributes skip it before the resolver, and
// Bloodrage (2687)'s self row resolves max 0.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.range, None, "Attributes & 0x404 → no range cell");
@@ -411,8 +365,7 @@ fn range_cell_on_real_data() {
assert_eq!(v.range, None, "the self row resolves max 0");
}
/// The three lines the 2026-07-25 reference captures pinned (decision 0620), each against the
/// REAL 5875 data. Skips without client data.
/// The required-item, chance-to-X and reagents lines on the real data.
#[test]
fn the_pinned_c6_lines_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
@@ -431,8 +384,8 @@ fn the_pinned_c6_lines_on_real_data() {
let objects = objs.get();
let store = empty_player();
// 1 · The wand Shoot (5019, class 2 / submask bit 19) — "Requires Wands", red with no
// wand worn. The same row feeds the cast-fail line's SINGULAR "Wand" (see `cast_fail`).
// 1. The wand Shoot (5019, class 2, subclass bit 19): "Requires Wands", red with no wand
// worn; the same row gives the cast-fail line its singular "Wand".
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
let v = spell_tooltip_view(
@@ -443,9 +396,8 @@ fn the_pinned_c6_lines_on_real_data() {
.expect("Shoot view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
// A multi-bit mask is named by ItemSubClassMask.dbc, not skipped (`0x52eef0` — we
// printed nothing here until `0x6e2380` was decoded): Parry's 0x2a5f3 is exactly the eleven
// melee subclasses, which that table names in one word.
// A multi-bit mask is named by `ItemSubClassMask.dbc` (`0x52eef0`, `0x6e2380`): Parry's
// 0x2a5f3 is the eleven melee subclasses, one name.
let parry = spells.catalog.get(3127).expect("Parry 3127");
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
let v = spell_tooltip_view(
@@ -456,17 +408,15 @@ fn the_pinned_c6_lines_on_real_data() {
.expect("Parry view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
// 2 · Attack (6603) — `Effect[0] == 78` omits the cast|cooldown line WHOLE, even though
// `Attributes & 0x40` is clear. Before the cast-line gate `0x52eb15` widened, this read
// "Instant".
// 2. Attack (6603): `Effect[0] == 78` omits the cast line (`0x52eb15`), though
// `Attributes & 0x40` is clear.
let d = spells.catalog.get(6603).expect("Attack 6603");
assert_eq!(d.effects[0], 78, "SPELL_EFFECT_ATTACK");
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(&objects, 0, None)).expect("Attack view");
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
// …and the chance line the same Effect[0] selects (`[0x52f5b1, 0x52f697)`). ATTACK
// BYPASSES the passive gate, which is the whole reason a non-passive Attack shows a crit
// line at all. No descriptor = no line; the percentages are already percents on the wire.
// The chance line `Effect[0]` selects (`0x52f5b1`): ATTACK skips the passive gate. No
// descriptor, no line; the wire's values are already percents.
assert_eq!(v.chance, None, "no player streamed yet");
let rated = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 100u32),
@@ -487,7 +437,7 @@ fn the_pinned_c6_lines_on_real_data() {
let v = spell_tooltip_view(81, &spells, &mut t.ctx_for(&objects, 0, None, Some(&rated)))
.expect("Dodge view");
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
// A spell naming none of the four effects has no line at all.
// None of the four effects: no line.
let v = spell_tooltip_view(
133,
&spells,
@@ -496,8 +446,8 @@ fn the_pinned_c6_lines_on_real_data() {
.expect("Fireball view");
assert_eq!(v.chance, None);
// 3 · Slow Fall (130) — "Reagents: Light Feather", inline-red while unowned (no store =
// owns nothing). The name rides the ask-once item cache, seeded here as the server would.
// 3. Slow Fall (130): "Reagents: Light Feather", red while unowned; the name comes from the
// item cache, seeded here as the server would.
let d = spells.catalog.get(130).expect("Slow Fall 130");
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
let v = spell_tooltip_view(
@@ -525,31 +475,25 @@ fn the_pinned_c6_lines_on_real_data() {
);
}
/// The "Locked" line's colour law (decision 0770) — the director's report: a door they held
/// the key for read RED, where the reference reads green.
///
/// The builder's own shape is "red unless the resolver found an opener", and *every* kind of
/// opener lands on the same green — so the mapping is a one-way test on `Unmet`, not a
/// per-arm table. A flag-locked object with no `Lock.dbc` row is the reference's
/// no-requirement arm and is green too: the flag alone never means "you can't".
/// Red only when the resolver finds no opener; a key, a known skill, no requirement and no
/// `Lock.dbc` row all read green.
#[test]
fn the_locked_line_greens_when_the_lock_can_be_opened() {
use crate::target::lock::LockOutcome;
// The report: the Scarlet Key in hand, the Armory Door in front of you.
// The Scarlet Key in hand at the Armory Door.
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenByKey(7146))),
TooltipTint::LockOpen,
"holding the key must read green"
);
// The same door without the key — unchanged, and the control for the fix.
// The same door without the key.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unmet)),
TooltipTint::Red,
"no key still reads red"
);
// A skill opener you know: green. (The reference would ramp this by margin; green is that
// ramp's comfortable rung — see `locked_line_tint`'s note.)
// A known skill opener: green, where the reference ramps by margin (`locked_line_tint`).
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenBySpell(2575))),
TooltipTint::LockOpen