ui: a skill lock you can open stops reading red

The GameObject tooltip's skill-lock line took the opener-*unknown* arm
unconditionally — `LOCKED_WITH_SPELL` in red — whatever the player's skill. The
known arm was simply not built, and the code said so where it was missing.

The reference forks on whether any known spell opens the slot's LockType
(`0x52abcb`): known takes `LOCKED_WITH_SPELL_KNOWN`, which carries the
requirement as a `%d` and the colour of the skill-difficulty ramp `0x529fa0`.
Built now, both halves.

grom reported it on a copper vein at Mining 70, which under the ramp is +69 over
the vein's requirement of 1 — the green rung, not the grey one he expected; grey
starts at +100, so a copper vein greys at Mining 101. Either way not red, and
red is what it showed.

The resolve is hoisted out of the `flag_locked` branch, because the "Locked"
line's colour and this line's known arm are two readings of one answer and must
not disagree with the click. `lock::skill_margin` recomputes the two numbers from
the opener the resolver already matched, with the same two functions its own
value test uses (`0x5f850f`), so hover and click cannot diverge on which opener
is in play.

Deviation, recorded at the constants: the three new rung RGBs are the standard
trade-skill palette, not values read out of the binary's table. Only the ramp's
shape is traced — five rungs, bands at +0/25/50/100 — and the rung a line lands
on is what the player reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-21 22:17:04 -06:00
co-authored by Claude Opus 5
parent 92b2e31577
commit a2da3e4da7
5 changed files with 144 additions and 27 deletions
+21
View File
@@ -223,6 +223,27 @@ pub(crate) fn spell_opens_lock(
})
}
/// `(provides, required)` for a skill slot whose opener the player knows — what the tooltip's
/// known arm colours its line by (`0x529fa0`'s ramp over the margin).
///
/// `opener` is the spell [`resolve_lock`] matched, so hover and click cannot disagree about *which*
/// opener is in play; this only recomputes the two numbers, with the same two functions the
/// resolver's own value test uses (`0x5f850f`). `None` when the spell is not in the catalog or has
/// no `OPEN_LOCK` effect, which is the resolver's `continue` and so shows the unknown arm.
pub(crate) fn skill_margin(
slot: &LockSlot,
opener: u32,
spells: Option<&crate::ui_action::Spells>,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
me: Option<&ObjectStore>,
go_level: u32,
) -> Option<(u32, u32)> {
let spell = spells?.catalog.get(opener)?;
let skill = spell_skill_value(me, skill_lines, opener);
let provides = spell.open_lock_skill(skill)?.max(0) as u32;
Some((provides, required_skill(slot, go_level).max(0) as u32))
}
/// The slot's requirement: `Skill[i]`, or `GAMEOBJECT_LEVEL × 5` when that is zero
/// (`0x5f84be`..`0x5f84ca`; `0x5f3490`..`0x5f349f` recomputes it for the `0xe0` toast's `%d`).
pub(crate) fn required_skill(slot: &LockSlot, go_level: u32) -> i32 {
+49 -25
View File
@@ -839,23 +839,24 @@ fn drive_mouseover_tooltip(
let mut lines: Vec<(String, TooltipTint)> = Vec::new();
// 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);
// Resolved once for both lines: the "Locked" line's colour and the skill line's known arm
// are two readings of the same answer, and the builder asks the resolver the click uses
// (`0x52ab14` → `0x5f83d0`), so neither may differ from the click.
let facts = crate::target::lock::go_facts(go_store.map(|s| (s, state)));
let mut matched = None;
let outcome = slots.map(|slots| {
crate::target::lock::resolve_lock(
slots,
&player_actions.spells,
go_inputs.spells.as_deref(),
go_inputs.skill_lines.as_ref().map(|s| &s.catalog),
self_store,
&go_inputs.objects,
facts,
&mut matched,
)
});
if flag_locked {
// 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| {
crate::target::lock::resolve_lock(
slots,
&player_actions.spells,
go_inputs.spells.as_deref(),
go_inputs.skill_lines.as_ref().map(|s| &s.catalog),
self_store,
&go_inputs.objects,
facts,
&mut matched,
)
});
if let Some(text) = keyed(&go_get, "LOCKED", &[]) {
lines.push((text, locked_line_tint(outcome)));
}
@@ -873,20 +874,43 @@ fn drive_mouseover_tooltip(
}
}
}
// 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.
// Silent on a flag-locked object, so a locked door names its key, never its
// lockpicking. Otherwise the reference forks on whether any known spell opens the
// slot's LockType (`0x52abcb`): the known arm is `LOCKED_WITH_SPELL_KNOWN`, which
// takes the requirement as a `%d` and the ramp's colour (`0x529fa0`); the unknown
// arm is `LOCKED_WITH_SPELL`, always red.
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
// `LOCKED_WITH_SPELL` (`0x52ac04`), named by `LockType.dbc` ("Pick Lock"); the
// reference takes the known arm iff any known spell opens it (`0x52abcb`).
// `LOCKED_WITH_SPELL` (`0x52ac04`), named by `LockType.dbc` ("Mining").
let word = go_inputs
.lock_types
.as_deref()
.and_then(|c| c.0.name(slot0.index));
if let Some(text) =
word.and_then(|w| keyed(&go_get, "LOCKED_WITH_SPELL", &[Arg::S(w)]))
{
lines.push((text, TooltipTint::Red));
// `matched` is set by the resolver on a LockType match, before its value test
// (`0x5f84f8`) — so it says "an opener is known", not "the skill suffices",
// which is the fork the reference takes here.
let known = matched.and_then(|opener| {
crate::target::lock::skill_margin(
&slot0,
opener,
go_inputs.spells.as_deref(),
go_inputs.skill_lines.as_ref().map(|s| &s.catalog),
self_store,
facts.level,
)
});
let line = match (word, known) {
(Some(w), Some((provides, required))) => keyed(
&go_get,
"LOCKED_WITH_SPELL_KNOWN",
&[Arg::S(w), Arg::D(required.into())],
)
.map(|t| (t, benilla_ui::script::skill_margin_tint(provides, required))),
(Some(w), None) => keyed(&go_get, "LOCKED_WITH_SPELL", &[Arg::S(w)])
.map(|t| (t, TooltipTint::Red)),
(None, _) => None,
};
if let Some(line) = line {
lines.push(line);
}
}
_ => {}
@@ -506,6 +506,47 @@ fn the_locked_line_greens_when_the_lock_can_be_opened() {
assert_eq!(locked_line_tint(None), TooltipTint::LockOpen);
}
/// The skill line's own ramp (`0x529fa0`), which is a different question from the "Locked" line's
/// two-way green/red above: a skill lock whose opener the player knows is coloured by how far past
/// the requirement they are, and only an *unknown* opener is flatly red.
///
/// grom's copper vein is the first row: Mining 70 against a requirement of 1 is +69, which is the
/// ramp's **green** rung — grey only starts at +100, so a copper vein greys at Mining 101. Either
/// way it is not red, and red is what every skill lock showed, whatever the player's skill,
/// because the known arm was simply not built.
#[test]
fn a_known_skill_openers_line_ramps_by_margin_instead_of_reading_red() {
use benilla_ui::script::skill_margin_tint;
// Mining 70 at a copper vein (`Lock.dbc` Skill = 1).
assert_eq!(
skill_margin_tint(70, 1),
TooltipTint::LockOpen,
"+69 over the requirement is the green rung, and must never be the unknown arm's red"
);
assert_eq!(
skill_margin_tint(101, 1),
TooltipTint::SkillGrey,
"the same vein greys at Mining 101, which is +100"
);
// The rungs, at their boundaries: +100 trivial, then +50, +25, +0.
assert_eq!(skill_margin_tint(101, 1), TooltipTint::SkillGrey);
assert_eq!(skill_margin_tint(100, 1), TooltipTint::LockOpen, "+99");
assert_eq!(skill_margin_tint(51, 1), TooltipTint::LockOpen, "+50");
assert_eq!(skill_margin_tint(50, 1), TooltipTint::SkillYellow, "+49");
assert_eq!(skill_margin_tint(26, 1), TooltipTint::SkillYellow, "+25");
assert_eq!(skill_margin_tint(25, 1), TooltipTint::SkillOrange, "+24");
// Exactly enough is the bottom rung, not red: red is the unknown-opener arm's, and a player
// who knows the opener and meets the requirement can gather.
assert_eq!(
skill_margin_tint(1, 1),
TooltipTint::SkillOrange,
"meeting it exactly is the orange rung"
);
// Short of it saturates rather than wrapping, and still is not this arm's business.
assert_eq!(skill_margin_tint(0, 100), TooltipTint::SkillOrange);
}
/// A trainer list that lands this frame is hoverable in this frame's tick: the trainer feed derives
/// the subjects the spell feed pushes.
#[test]
+1 -1
View File
@@ -151,7 +151,7 @@ mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
pub use tooltip_unit::TooltipTint;
pub use tooltip_unit::{skill_margin_tint, TooltipTint};
mod trade;
mod tradeskill;
mod trainer;
+32 -1
View File
@@ -16,8 +16,16 @@ 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.
/// `0xc0d420` = `0xff40c040`, the satisfiable-lock green, and the ramp's green rung.
const LOCK_OPEN: [f32; 4] = [64.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0, 1.0];
/// The other three rungs of the skill-difficulty ramp `0x529fa0`, whose green rung is
/// [`LOCK_OPEN`] and whose bottom rung is [`RED`]. Deviation: these three RGBs are the standard
/// trade-skill palette, not values read out of the binary's table — only the ramp's *shape*
/// (five rungs, bands at +0/25/50/100 over the requirement) is traced. The rung a line lands on
/// is what matters to the player and that is faithful; a channel or two may not be.
const SKILL_GREY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0];
const SKILL_YELLOW: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
const SKILL_ORANGE: [f32; 4] = [1.0, 128.0 / 255.0, 64.0 / 255.0, 1.0];
/// `0xffffd200`, the unit name's colour until FrameXML recolours it by reaction.
const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
@@ -34,6 +42,26 @@ pub enum TooltipTint {
/// The satisfiable "Locked" line: `0xc0d420` = `0xff40c040`, the green rung of the
/// skill-difficulty ramp `0x529fa0`, not the ordinary green `0xc0d3ac` = `0xff00ff00`.
LockOpen,
/// The three rungs of the ramp `0x529fa0` that are neither its green ([`TooltipTint::LockOpen`])
/// nor its red ([`TooltipTint::Red`]), for the "Requires %s (%d)" line of a skill lock whose
/// opener the player knows: trivial at +100 over the requirement, then +50 and +25.
SkillGrey,
SkillYellow,
SkillOrange,
}
/// The rung of `0x529fa0` for a skill lock the player can open: `have` is what the opener provides,
/// `required` the slot's requirement. Bands at +0/25/50/100, as the reference's table is indexed.
///
/// The unmet case is the caller's: it takes the *unknown-opener* arm's red and a different
/// GlobalString, so it never reaches here.
pub fn skill_margin_tint(have: u32, required: u32) -> TooltipTint {
match have.saturating_sub(required) {
m if m >= 100 => TooltipTint::SkillGrey,
m if m >= 50 => TooltipTint::LockOpen,
m if m >= 25 => TooltipTint::SkillYellow,
_ => TooltipTint::SkillOrange,
}
}
/// The rank word's GlobalString key, from the builder's table `0x854158` (indexed at `0x52a5bd`
@@ -415,6 +443,9 @@ impl super::UiScript {
TooltipTint::White => WHITE,
TooltipTint::Red => RED,
TooltipTint::LockOpen => LOCK_OPEN,
TooltipTint::SkillGrey => SKILL_GREY,
TooltipTint::SkillYellow => SKILL_YELLOW,
TooltipTint::SkillOrange => SKILL_ORANGE,
};
append_line(&self.lua, &wrapper, (text.clone(), colour), None, false)?;
}