every line is a key, four find their sound, and the ding prints once
- the last app-side lines become GlobalStrings keys — tooltip cells, party, petition, duel, quest, PvP and rest lines read the install's own wording, so a non-enUS chain shows its own text (2045, 2080) - a locked chest says Requires Pick Lock, ammo and thrown weapons drop their invented slot word, a met set bonus reads Set: without the count, and a 100 s cooldown reads 1.67 min as the template prints it - the level-up block and the free-professions line printed twice in chat and print once; a declined invite, a friend coming online, a duel request and a failed quest play the sound 1.12 gives them - Duel cancelled. is the yellow line at the top of the screen, and a trainer refusal or a failed quest with no cached template shows nothing, as on 1.12 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -297,10 +297,22 @@ pub(super) fn install_tooltip_method(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
.map(|v| v.description.clone())
|
||||
})
|
||||
.flatten();
|
||||
// `TOOLTIP_TALENT_RANK` = "Rank %d/%d" (`0x854a2c`, pushed at `0x52b213`) —
|
||||
// the key, filled with the two counts, never a sentence of ours (decision 2045).
|
||||
// An install whose string table lacks it shows no rank line at all.
|
||||
let rank_line = crate::strings::global(lua, "TOOLTIP_TALENT_RANK").map(|tmpl| {
|
||||
crate::strings::fill(
|
||||
&tmpl,
|
||||
&[
|
||||
crate::strings::Arg::D(t.rank.into()),
|
||||
crate::strings::Arg::D(t.max_rank.into()),
|
||||
],
|
||||
)
|
||||
});
|
||||
(
|
||||
t.display_spell,
|
||||
TalentLines {
|
||||
rank_line: format!("Rank {}/{}", t.rank, t.max_rank),
|
||||
rank_line,
|
||||
reqs: t.req_lines.clone(),
|
||||
next_spell: t.next_spell,
|
||||
next_desc,
|
||||
|
||||
@@ -106,11 +106,27 @@ fn learn_talent_queues_for_the_app_drain() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// SetTalent = the spell builder + the talent interleave: name, "Rank r/m" white, cost line,
|
||||
/// gold description, "Next rank:" + the next rank's gold description, green "Click to learn".
|
||||
/// A stand-in string table for the talent tail's three keys — **deliberately not the shipped
|
||||
/// wording**, because what is under test is which key each line reaches and what fills it, never
|
||||
/// what the sentence says (decision 2045).
|
||||
fn seed_talent_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
TOOLTIP_TALENT_RANK = "[RANK %d/%d]"
|
||||
TOOLTIP_TALENT_NEXT_RANK = "[NEXT_RANK]"
|
||||
TOOLTIP_TALENT_LEARN = "[LEARN]"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// SetTalent = the spell builder + the talent interleave: name, TOOLTIP_TALENT_RANK white, cost
|
||||
/// line, gold description, TOOLTIP_TALENT_NEXT_RANK + the next rank's gold description, green
|
||||
/// TOOLTIP_TALENT_LEARN.
|
||||
#[test]
|
||||
fn set_talent_renders_the_interleaved_tooltip() {
|
||||
let mut s = script();
|
||||
seed_talent_strings(&mut s);
|
||||
s.set_talents(one_tab_state());
|
||||
s.set_spell_tooltip(
|
||||
11070,
|
||||
@@ -136,13 +152,13 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetTalent(1, 1)
|
||||
assert(tt:IsShown(), "SetTalent shows")
|
||||
-- name, Rank 3/5, Instant, desc, Next rank:, next desc, Click to learn = 7 lines.
|
||||
-- name, rank, Instant, desc, next-rank header, next desc, learn hint = 7 lines.
|
||||
assert(tt:NumLines() == 7, "got " .. tt:NumLines())
|
||||
assert(TTTextLeft1:GetText() == "Improved Fireball")
|
||||
assert(TTTextLeft2:GetText() == "Rank 3/5")
|
||||
assert(TTTextLeft2:GetText() == "[RANK 3/5]", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "Instant")
|
||||
assert(TTTextLeft5:GetText() == "Next rank:")
|
||||
assert(TTTextLeft7:GetText() == "Click to learn")
|
||||
assert(TTTextLeft5:GetText() == "[NEXT_RANK]")
|
||||
assert(TTTextLeft7:GetText() == "[LEARN]")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -151,7 +167,7 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
let quads = s.extract();
|
||||
let green = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Click to learn" && c[0] < 1e-6 && (c[1] - 1.0).abs() < 1e-6)
|
||||
if t == "[LEARN]" && c[0] < 1e-6 && (c[1] - 1.0).abs() < 1e-6)
|
||||
});
|
||||
assert!(green, "the learn hint is green");
|
||||
assert!(s.take_errors().is_empty());
|
||||
@@ -162,6 +178,7 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
#[test]
|
||||
fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
let mut s = script();
|
||||
seed_talent_strings(&mut s);
|
||||
s.set_talents(one_tab_state());
|
||||
// No spell view pushed for Ignite (11119): the render falls back, the ask is recorded.
|
||||
s.run(
|
||||
@@ -171,7 +188,7 @@ fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetTalent(1, 2)
|
||||
assert(tt:IsShown())
|
||||
assert(TT2TextLeft1:GetText() == "Rank 0/5", "fallback shows the rank head")
|
||||
assert(TT2TextLeft1:GetText() == "[RANK 0/5]", "fallback shows the rank head")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -195,7 +212,7 @@ fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
local tt = getglobal("TT2")
|
||||
tt:SetOwner(getglobal("TB2"), "ANCHOR_RIGHT")
|
||||
tt:SetTalent(1, 2)
|
||||
-- name, Rank 0/5, req(red), desc = 4 lines; rank 0 has no next block, locked has no hint.
|
||||
-- name, rank, req(red), desc = 4 lines; rank 0 has no next block, locked has no hint.
|
||||
assert(tt:NumLines() == 4, "got " .. tt:NumLines())
|
||||
assert(TT2TextLeft3:GetText() == "Requires 5 points in Improved Fireball")
|
||||
"#,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::script::*;
|
||||
/// The shopping-compare pipeline end-to-end (0274 P4): a bag-ring hover on the main GameTooltip
|
||||
/// with shift held fires `SHOW_COMPARE_TOOLTIP` once per finger slot; a ref-shaped listener
|
||||
/// (PaperDollFrame.lua:621-640) seats ShoppingTooltip1/2, whose ARMED `SetInventoryItem` renders
|
||||
/// the byte law's compare shape — gray "Currently Equipped", WHITE name, the compact cut (the
|
||||
/// the byte law's compare shape — the gray CURRENTLY_EQUIPPED header, WHITE name, the compact cut (the
|
||||
/// description never prints). Releasing shift hides the pair; a shift-up hover fires nothing
|
||||
/// until the rising edge.
|
||||
#[test]
|
||||
@@ -135,7 +135,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
assert(table.getn(compare_calls) == 2, "two ring compares, got " .. table.getn(compare_calls))
|
||||
assert(compare_calls[1] == "11:1" and compare_calls[2] == "12:2", "slot:index order")
|
||||
assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown())
|
||||
assert(ShoppingTooltip1TextLeft1:GetText() == "Currently Equipped")
|
||||
assert(ShoppingTooltip1TextLeft1:GetText() == "[CURRENTLY_EQUIPPED]")
|
||||
assert(ShoppingTooltip1TextLeft2:GetText() == "Old Loop")
|
||||
assert(ShoppingTooltip2TextLeft2:GetText() == "Older Loop")
|
||||
-- The compact cut at 0x52e14c: the description never prints on a compare.
|
||||
@@ -167,10 +167,10 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
})
|
||||
.unwrap_or([0.0; 4])
|
||||
};
|
||||
let gray = color_of("Currently Equipped");
|
||||
let gray = color_of("[CURRENTLY_EQUIPPED]");
|
||||
assert!(
|
||||
(gray[0] - 128.0 / 255.0).abs() < 0.01 && (gray[1] - 128.0 / 255.0).abs() < 0.01,
|
||||
"Currently Equipped is gray, got {gray:?}"
|
||||
"CURRENTLY_EQUIPPED is gray, got {gray:?}"
|
||||
);
|
||||
let name = color_of("Old Loop");
|
||||
assert_eq!(name, [1.0, 1.0, 1.0, 1.0], "compare name is WHITE");
|
||||
@@ -493,7 +493,7 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
s.eval::<bool>(
|
||||
r#"local n = ShoppingTooltip1:NumLines()
|
||||
for i = 1, n do
|
||||
if getglobal("ShoppingTooltip1TextLeft"..i):GetText() == "Currently Equipped" then
|
||||
if getglobal("ShoppingTooltip1TextLeft"..i):GetText() == "[CURRENTLY_EQUIPPED]" then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,20 +37,21 @@ fn item_line_law_and_red_requirements() {
|
||||
texts,
|
||||
vec![
|
||||
"Ravager",
|
||||
"Binds when equipped",
|
||||
"Two-Hand",
|
||||
"[ITEM_BIND_ON_EQUIP]",
|
||||
"[INVTYPE_2HWEAPON]",
|
||||
// School 0 is physical: the reference names no school word for it either.
|
||||
"68 - 103 Damage",
|
||||
"+ 2 - 4 Shadow Damage",
|
||||
"(25.3 damage per second)",
|
||||
"+ 2 - 4 [SCHOOL5] Damage",
|
||||
"[DPS 25.3]",
|
||||
// Display order, NOT wire order: the fixture feeds (Stamina, Strength) but the
|
||||
// 0x808e88 table prints Strength first (STR,AGI,STA,INT,SPI,HP,MANA).
|
||||
"+9 Strength",
|
||||
"+12 Stamina",
|
||||
"+10 Shadow Resistance",
|
||||
"Durability 90 / 90",
|
||||
"Classes: Warrior, Rogue",
|
||||
"Requires Level 37",
|
||||
"Chance on hit: Ravager",
|
||||
"[MOD_STRENGTH +9]",
|
||||
"[MOD_STAMINA +12]",
|
||||
"[RESIST_SINGLE +10 [SCHOOL5]]",
|
||||
"[DURABILITY 90/90]",
|
||||
"[CLASSES Warrior, Rogue]",
|
||||
"[MIN_LEVEL 37]",
|
||||
"[ONPROC] Ravager",
|
||||
"\"A wicked axe of the Scarlet Crusade.\"",
|
||||
],
|
||||
"the verified line order (0276)"
|
||||
@@ -109,13 +110,9 @@ fn red_lines_track_player_state() {
|
||||
.unwrap_or_else(|| panic!("no line starting {needle}"))
|
||||
.1
|
||||
};
|
||||
assert_eq!(find("[MIN_LEVEL"), [1.0, 1.0, 1.0, 1.0], "60 ≥ 37 → white");
|
||||
assert_eq!(
|
||||
find("Requires Level"),
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
"60 ≥ 37 → white"
|
||||
);
|
||||
assert_eq!(
|
||||
find("Classes:"),
|
||||
find("[CLASSES"),
|
||||
[1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0],
|
||||
"mage → red"
|
||||
);
|
||||
@@ -170,26 +167,26 @@ fn verified_families_signable_locked_resists_known() {
|
||||
texts,
|
||||
vec![
|
||||
"Sealed Charter",
|
||||
"<Right Click for Details>",
|
||||
"Unique",
|
||||
"This Item Begins a Quest",
|
||||
"Locked",
|
||||
"+5 to All Resistances",
|
||||
"Already known",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_UNIQUE]",
|
||||
"[ITEM_STARTS_QUEST]",
|
||||
"[LOCKED]",
|
||||
"[RESIST_ALL +5]",
|
||||
"[ITEM_SPELL_KNOWN]",
|
||||
"\"Sign here.\"",
|
||||
],
|
||||
"the verified gated families in the verified order"
|
||||
);
|
||||
let color = |needle: &str| lines.iter().find(|(t, _)| t == needle).unwrap().1;
|
||||
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||||
assert_eq!(color("Locked"), red, "LOCKED is red");
|
||||
assert_eq!(color("[LOCKED]"), red, "LOCKED is red");
|
||||
assert_eq!(
|
||||
color("Already known"),
|
||||
color("[ITEM_SPELL_KNOWN]"),
|
||||
red,
|
||||
"SPELL_KNOWN is unconditional red"
|
||||
);
|
||||
assert_eq!(
|
||||
color("<Right Click for Details>"),
|
||||
color("[ITEM_SIGNABLE]"),
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
"SIGNABLE is green"
|
||||
);
|
||||
@@ -263,7 +260,11 @@ fn proficiency_and_reputation_reds() {
|
||||
.1
|
||||
};
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Two-Hand"), white, "proficient slot is white");
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_2HWEAPON]"),
|
||||
white,
|
||||
"proficient slot is white"
|
||||
);
|
||||
assert_eq!(
|
||||
right_color(&mut s, "Axe"),
|
||||
white,
|
||||
@@ -284,7 +285,7 @@ fn proficiency_and_reputation_reds() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(
|
||||
color(&lines, "Two-Hand"),
|
||||
color(&lines, "[INVTYPE_2HWEAPON]"),
|
||||
white,
|
||||
"slot survives a hard miss"
|
||||
);
|
||||
@@ -302,7 +303,11 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(871)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Two-Hand"), red, "alt-usable reds the slot");
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_2HWEAPON]"),
|
||||
red,
|
||||
"alt-usable reds the slot"
|
||||
);
|
||||
assert_eq!(
|
||||
right_color(&mut s, "Axe"),
|
||||
white,
|
||||
@@ -326,8 +331,10 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
// `INVTYPE_WEAPONOFFHAND`, NOT `INVTYPE_SHIELD` — one enUS sentence, two keys, and only the
|
||||
// weapon one belongs on an InventoryType 22.
|
||||
assert_eq!(
|
||||
color(&lines, "Off Hand"),
|
||||
color(&lines, "[INVTYPE_WEAPONOFFHAND]"),
|
||||
red,
|
||||
"no Dual Wield reds the slot"
|
||||
);
|
||||
@@ -341,7 +348,11 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Off Hand"), white, "Dual Wield clears it");
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_WEAPONOFFHAND]"),
|
||||
white,
|
||||
"Dual Wield clears it"
|
||||
);
|
||||
// An item class with NO proficiency entry never reds (the map only ever holds classes
|
||||
// the server sent masks for).
|
||||
s.set_item_template(
|
||||
@@ -358,7 +369,7 @@ fn proficiency_and_reputation_reds() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(
|
||||
color(&lines, "Chest"),
|
||||
color(&lines, "[INVTYPE_CHEST]"),
|
||||
white,
|
||||
"a class with no mask entry stays white"
|
||||
);
|
||||
@@ -377,7 +388,7 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(889)
|
||||
assert(TTTextLeft2:GetText() == "Finger")
|
||||
assert(TTTextLeft2:GetText() == "[INVTYPE_FINGER]")
|
||||
assert(TTTextRight2:GetText() == nil or TTTextRight2:GetText() == "",
|
||||
"a ring never prints its Miscellaneous type")
|
||||
"#,
|
||||
@@ -413,7 +424,7 @@ fn required_level_one_is_hidden() {
|
||||
assert(tt:NumLines() == 1, "req 1 hides like req 0, got " .. tt:NumLines())
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(13)
|
||||
assert(tt:NumLines() == 2, "req 2 prints, got " .. tt:NumLines())
|
||||
assert(TTTextLeft2:GetText() == "Requires Level 2")
|
||||
assert(TTTextLeft2:GetText() == "[MIN_LEVEL 2]")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -516,11 +527,11 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
texts,
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Guild Name: BTC",
|
||||
"Guild Master: Twowarrior",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique",
|
||||
"[GUILD_CHARTER_TITLE BTC]",
|
||||
"[GUILD_CHARTER_CREATOR Twowarrior]",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]",
|
||||
],
|
||||
"the two guild lines sit ABOVE the green line, not below it"
|
||||
);
|
||||
@@ -542,10 +553,10 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
hover(&mut s, 2),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Guild Name: BTC",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
"[GUILD_CHARTER_TITLE BTC]",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]"
|
||||
],
|
||||
"an unresolved owner withholds ITS line only — the repaint fills it"
|
||||
);
|
||||
@@ -553,9 +564,9 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
hover(&mut s, 3),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]"
|
||||
],
|
||||
"no record yet: exactly the plate we shipped before, not a blank one"
|
||||
);
|
||||
@@ -563,11 +574,11 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
hover(&mut s, 4),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Petition: Something",
|
||||
"Created by Someone",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
"[PETITION_TITLE Something]",
|
||||
"[PETITION_CREATOR Someone]",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]"
|
||||
],
|
||||
"the record's charter bit picks the key family"
|
||||
);
|
||||
@@ -650,7 +661,7 @@ fn instance_tail_creator_and_readable() {
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["Plain Letter", "Written by One", "<Right Click to Read>"],
|
||||
vec!["Plain Letter", "[WRITTEN_BY One]", "[ITEM_READABLE]"],
|
||||
"the letter: writer line + instance-gated READABLE"
|
||||
);
|
||||
assert_eq!(lines[1].1, [1.0, 1.0, 1.0, 1.0], "WRITTEN_BY is white");
|
||||
@@ -659,14 +670,18 @@ fn instance_tail_creator_and_readable() {
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 2)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(texts, vec!["Plain Letter", "<Right Click to Read>"]);
|
||||
assert_eq!(texts, vec!["Plain Letter", "[ITEM_READABLE]"]);
|
||||
// Crafted + locked: the green-escaped Made-by; no open line while LockID gates it.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 3)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["Heavy Chest", "Locked", "|cff00ff00<Made by Geoffrey>|r"],
|
||||
vec![
|
||||
"Heavy Chest",
|
||||
"[LOCKED]",
|
||||
"|cff00ff00[CREATED_BY Geoffrey]|r"
|
||||
],
|
||||
"CREATED_BY carries the string's own green escape; locked chest hides OPENABLE"
|
||||
);
|
||||
// The instance UNLOCKED bit retires the LOCKED line AND satisfies the openable lock sub-gate.
|
||||
@@ -674,14 +689,14 @@ fn instance_tail_creator_and_readable() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(texts, vec!["Heavy Chest", "<Right Click to Open>"]);
|
||||
assert_eq!(texts, vec!["Heavy Chest", "[ITEM_OPENABLE]"]);
|
||||
assert_eq!(lines[1].1, [0.0, 1.0, 0.0, 1.0], "OPENABLE is green");
|
||||
// The director's case: a lockless LOOTABLE template (a clam) is openable outright — name +
|
||||
// the green line, nothing between them.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 5)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(texts, vec!["Small Barnacled Clam", "<Right Click to Open>"]);
|
||||
assert_eq!(texts, vec!["Small Barnacled Clam", "[ITEM_OPENABLE]"]);
|
||||
// The same clam mid-cooldown takes SetBagItem's OTHER leg (p6=1) — the openable tree is
|
||||
// skipped wholesale, so the green line is gone (the reference prints ITEM_COOLDOWN_TIME in
|
||||
// its place; unfed here). `hasCooldown`, the binding's own return, is the same boolean.
|
||||
@@ -765,23 +780,35 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
|
||||
let bind_line = |s: &mut UiScript| {
|
||||
lines_of(s)
|
||||
.into_iter()
|
||||
.find(|(t, _)| t == "Soulbound" || t.starts_with("Binds when") || t == "Quest Item")
|
||||
.find(|(t, _)| {
|
||||
matches!(
|
||||
t.as_str(),
|
||||
"[ITEM_SOULBOUND]"
|
||||
| "[ITEM_BIND_ON_PICKUP]"
|
||||
| "[ITEM_BIND_ON_EQUIP]"
|
||||
| "[ITEM_BIND_ON_USE]"
|
||||
| "[ITEM_BIND_QUEST]"
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no bind line at all"))
|
||||
};
|
||||
let (text, color) = bind_line(&mut s);
|
||||
assert_eq!(text, "Soulbound", "a runtime-bound instance overrides §6");
|
||||
assert_eq!(
|
||||
text, "[ITEM_SOULBOUND]",
|
||||
"a runtime-bound instance overrides §6"
|
||||
);
|
||||
assert_eq!(color, [1.0, 1.0, 1.0, 1.0], "§6 is white");
|
||||
|
||||
// Control 1: the SAME item, instance not bound — the template's bonding stands.
|
||||
s.run(r#"TT:SetBagItem(0, 2)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Binds when equipped");
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
|
||||
|
||||
// Control 2: a TEMPLATE hover carries no instance at all, so nothing can override.
|
||||
s.run(r#"TT:SetItemById(871)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Binds when equipped");
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
|
||||
|
||||
// Control 3: the override's other arm is ITEM_BIND_QUEST — the same text 4|5 already print.
|
||||
s.run(r#"TT:SetBagItem(0, 3)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Quest Item");
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_QUEST]");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ fn bag_item_money_law_and_fallback() {
|
||||
TT:SetBagItem(0, 2)
|
||||
assert(money_fired == nil, "unsellable fires no money")
|
||||
local last = getglobal("TTTextLeft" .. TT:NumLines())
|
||||
assert(last:GetText() == "No sell price", "ITEM_UNSELLABLE line")
|
||||
assert(last:GetText() == "[ITEM_UNSELLABLE]", "ITEM_UNSELLABLE line")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -160,7 +160,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
|
||||
s.resolve();
|
||||
};
|
||||
answer(&mut s);
|
||||
// name 14 + gap 2 + "1 Charge" 14 + gap 2 + description 36 + 2·pad 20 = 88.
|
||||
// name 14 + gap 2 + the charges line 14 + gap 2 + description 36 + 2·pad 20 = 88.
|
||||
s.run(
|
||||
r#"
|
||||
assert(TT:GetWidth() == 270, "wrap column + padding, got " .. TT:GetWidth())
|
||||
@@ -285,17 +285,17 @@ fn real_instance_hover_renders_live_durability() {
|
||||
found = nil
|
||||
for i = 1, tt:NumLines() do
|
||||
local t = getglobal("TTTextLeft" .. i):GetText()
|
||||
if t and string.find(t, "Durability") then found = t end
|
||||
if t and string.find(t, "DURABILITY") then found = t end
|
||||
end
|
||||
assert(found == "Durability 30 / 40", "live pair on a bag hover, got " .. tostring(found))
|
||||
assert(found == "[DURABILITY 30/40]", "live pair on a bag hover, got " .. tostring(found))
|
||||
-- The template/link hover of the SAME item keeps the authored full pair.
|
||||
tt:SetItemById(2264)
|
||||
found = nil
|
||||
for i = 1, tt:NumLines() do
|
||||
local t = getglobal("TTTextLeft" .. i):GetText()
|
||||
if t and string.find(t, "Durability") then found = t end
|
||||
if t and string.find(t, "DURABILITY") then found = t end
|
||||
end
|
||||
assert(found == "Durability 40 / 40", "template hover stays full, got " .. tostring(found))
|
||||
assert(found == "[DURABILITY 40/40]", "template hover stays full, got " .. tostring(found))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -346,9 +346,9 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
found = nil
|
||||
for i = 1, tt:NumLines() do
|
||||
local t = getglobal("TTTextLeft" .. i):GetText()
|
||||
if t and string.find(t, "Durability") then found = t end
|
||||
if t and string.find(t, "DURABILITY") then found = t end
|
||||
end
|
||||
assert(found == "Durability 0 / 40", "broken gear shows its true 0, got " .. tostring(found))
|
||||
assert(found == "[DURABILITY 0/40]", "broken gear shows its true 0, got " .. tostring(found))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -358,7 +358,7 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let dur = lines
|
||||
.iter()
|
||||
.find(|(t, _)| t.starts_with("Durability"))
|
||||
.find(|(t, _)| t.starts_with("[DURABILITY"))
|
||||
.expect("the durability line renders");
|
||||
assert_eq!(
|
||||
dur.1,
|
||||
@@ -431,7 +431,8 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
.unwrap_or_else(|| panic!("no {needle} line in {texts:?}"))
|
||||
};
|
||||
assert!(
|
||||
at("+7 Nature Resistance") < at("Agility +15") && at("Agility +15") < at("Durability"),
|
||||
at("[RESIST_SINGLE +7 [SCHOOL3]]") < at("Agility +15")
|
||||
&& at("Agility +15") < at("[DURABILITY"),
|
||||
"the enchant line sits between resistances and durability: {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -564,7 +565,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
EnchantView {
|
||||
slot: 1,
|
||||
name: "Rockbiter Weapon".into(),
|
||||
remaining_ms: Some(275_000), // 4 min 35 s → "(5 min)"
|
||||
remaining_ms: Some(275_000), // 4 min 35 s → the MIN arm, ceiled to 5
|
||||
charges: 5,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -606,7 +607,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
texts.contains(&"Rockbiter Weapon (5 min) (5 Charges)"),
|
||||
texts.contains(&"[ENCH Rockbiter Weapon 5 m] ([CHARGES 5 x])"),
|
||||
"one line: name, countdown, charges — got {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -623,7 +624,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
/// (`0x5d8b00`, the same string `GetLootSlotInfo` returns), so the plate reads "… of the Monkey".
|
||||
///
|
||||
/// This is the reported bug's exact shape: through the template path (`SetItemById`) the same
|
||||
/// hover printed `<Random enchantment>` until the item reached a bag.
|
||||
/// hover printed the ITEM_RANDOM_ENCHANT placeholder until the item reached a bag.
|
||||
#[test]
|
||||
fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
let mut s = script();
|
||||
@@ -689,7 +690,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
!texts.contains(&"<Random enchantment>"),
|
||||
!texts.contains(&"[ITEM_RANDOM_ENCHANT]"),
|
||||
"a block source never reaches the placeholder arm — got {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -749,7 +750,7 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
|
||||
.map(|(t, _)| t)
|
||||
.collect();
|
||||
assert!(
|
||||
!texts.iter().any(|t| t == "<Random enchantment>"),
|
||||
!texts.iter().any(|t| t == "[ITEM_RANDOM_ENCHANT]"),
|
||||
"no placeholder on a block source — got {texts:?}"
|
||||
);
|
||||
}
|
||||
@@ -801,7 +802,7 @@ fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
.map(|(t, _)| t)
|
||||
.collect();
|
||||
assert!(
|
||||
!texts.iter().any(|t| t == "<Random enchantment>"),
|
||||
!texts.iter().any(|t| t == "[ITEM_RANDOM_ENCHANT]"),
|
||||
"a link never reaches the placeholder arm — got {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -815,7 +816,7 @@ fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `<Random enchantment>` (§E5) — the template-only placeholder: a random-property item with NO
|
||||
/// `ITEM_RANDOM_ENCHANT` (§E5) — the template-only placeholder: a random-property item with NO
|
||||
/// instance to read a roll from prints it, green, and the per-slot lines and this one are mutually
|
||||
/// exclusive by construction. The control is the same template hovered as a real enchanted
|
||||
/// instance: the roll is known, so the placeholder gives way to the slot lines.
|
||||
@@ -844,7 +845,7 @@ fn random_property_template_hover_shows_the_placeholder() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let placeholder = lines
|
||||
.iter()
|
||||
.find(|(t, _)| t == "<Random enchantment>")
|
||||
.find(|(t, _)| t == "[ITEM_RANDOM_ENCHANT]")
|
||||
.expect("the template hover shows the placeholder");
|
||||
assert_eq!(placeholder.1, [0.0, 1.0, 0.0, 1.0], "green");
|
||||
|
||||
@@ -876,7 +877,7 @@ fn random_property_template_hover_shows_the_placeholder() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
!texts.contains(&"<Random enchantment>") && texts.contains(&"Stamina +7"),
|
||||
!texts.contains(&"[ITEM_RANDOM_ENCHANT]") && texts.contains(&"Stamina +7"),
|
||||
"an instance's known roll replaces the placeholder — got {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,120 @@
|
||||
//! protocol (engine fires `OnTooltipAddMoney` — merchant open + real instance + repair off),
|
||||
//! and the in-flight-template fallback.
|
||||
|
||||
use super::common::script;
|
||||
use super::common::script as bare_script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A stand-in `GlobalStrings.lua` for the item builder — **deliberately not the shipped wording**.
|
||||
///
|
||||
/// Every sentence the builder draws is a key resolved off the VM's own globals (decision 2045),
|
||||
/// and what these tests establish is *which key* each line reaches and what fills it, never what
|
||||
/// the sentence says. Naming each value after its own key is the point: `INVTYPE_SHIELD`,
|
||||
/// `INVTYPE_WEAPONOFFHAND` and `SECONDARYHANDSLOT` all read "Off Hand" in enUS, `ITEM_REQ_SKILL`
|
||||
/// shares "Requires %s" with the three `LOCKED_WITH_*` keys, and an assertion on the English
|
||||
/// would pass on every one of them. Read an expected line here as "this key, filled with these".
|
||||
///
|
||||
/// The values keep each shipped template's *hole shape* — that is what proves the fill — and
|
||||
/// nothing else about it.
|
||||
fn seed_item_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
CURRENTLY_EQUIPPED = "[CURRENTLY_EQUIPPED]"
|
||||
GUILD_CHARTER_TITLE = "[GUILD_CHARTER_TITLE %s]"
|
||||
GUILD_CHARTER_CREATOR = "[GUILD_CHARTER_CREATOR %s]"
|
||||
PETITION_TITLE = "[PETITION_TITLE %s]"
|
||||
PETITION_CREATOR = "[PETITION_CREATOR %s]"
|
||||
ITEM_SIGNABLE = "[ITEM_SIGNABLE]"
|
||||
ITEM_CONJURED = "[ITEM_CONJURED]"
|
||||
ITEM_SOULBOUND = "[ITEM_SOULBOUND]"
|
||||
ITEM_BIND_QUEST = "[ITEM_BIND_QUEST]"
|
||||
ITEM_BIND_ON_PICKUP = "[ITEM_BIND_ON_PICKUP]"
|
||||
ITEM_BIND_ON_EQUIP = "[ITEM_BIND_ON_EQUIP]"
|
||||
ITEM_BIND_ON_USE = "[ITEM_BIND_ON_USE]"
|
||||
ITEM_UNIQUE = "[ITEM_UNIQUE]"
|
||||
ITEM_UNIQUE_MULTIPLE = "[ITEM_UNIQUE_MULTIPLE %d]"
|
||||
ITEM_STARTS_QUEST = "[ITEM_STARTS_QUEST]"
|
||||
LOCKED = "[LOCKED]"
|
||||
|
||||
INVTYPE_HEAD = "[INVTYPE_HEAD]"; INVTYPE_NECK = "[INVTYPE_NECK]"
|
||||
INVTYPE_SHOULDER = "[INVTYPE_SHOULDER]"; INVTYPE_BODY = "[INVTYPE_BODY]"
|
||||
INVTYPE_CHEST = "[INVTYPE_CHEST]"; INVTYPE_WAIST = "[INVTYPE_WAIST]"
|
||||
INVTYPE_LEGS = "[INVTYPE_LEGS]"; INVTYPE_FEET = "[INVTYPE_FEET]"
|
||||
INVTYPE_WRIST = "[INVTYPE_WRIST]"; INVTYPE_HAND = "[INVTYPE_HAND]"
|
||||
INVTYPE_FINGER = "[INVTYPE_FINGER]"; INVTYPE_TRINKET = "[INVTYPE_TRINKET]"
|
||||
INVTYPE_WEAPON = "[INVTYPE_WEAPON]"; INVTYPE_SHIELD = "[INVTYPE_SHIELD]"
|
||||
INVTYPE_RANGED = "[INVTYPE_RANGED]"; INVTYPE_CLOAK = "[INVTYPE_CLOAK]"
|
||||
INVTYPE_2HWEAPON = "[INVTYPE_2HWEAPON]"; INVTYPE_TABARD = "[INVTYPE_TABARD]"
|
||||
INVTYPE_ROBE = "[INVTYPE_ROBE]"; INVTYPE_WEAPONMAINHAND = "[INVTYPE_WEAPONMAINHAND]"
|
||||
INVTYPE_WEAPONOFFHAND = "[INVTYPE_WEAPONOFFHAND]"; INVTYPE_HOLDABLE = "[INVTYPE_HOLDABLE]"
|
||||
INVTYPE_RELIC = "[INVTYPE_RELIC]"
|
||||
-- INVTYPE_AMMO / _THROWN / _RANGEDRIGHT / _QUIVER are DELIBERATELY absent: 1.12's
|
||||
-- GlobalStrings.lua does not ship them either, so those four types draw no slot cell.
|
||||
|
||||
SPELL_SCHOOL1_CAP = "[SCHOOL1]"; SPELL_SCHOOL2_CAP = "[SCHOOL2]"
|
||||
SPELL_SCHOOL3_CAP = "[SCHOOL3]"; SPELL_SCHOOL4_CAP = "[SCHOOL4]"
|
||||
SPELL_SCHOOL5_CAP = "[SCHOOL5]"; SPELL_SCHOOL6_CAP = "[SCHOOL6]"
|
||||
|
||||
DPS_TEMPLATE = "[DPS %.1f]"
|
||||
ARMOR_TEMPLATE = "[ARMOR %d]"
|
||||
SHIELD_BLOCK_TEMPLATE = "[BLOCK %d]"
|
||||
ITEM_MOD_MANA = "[MOD_MANA %c%d]"
|
||||
ITEM_MOD_HEALTH = "[MOD_HEALTH %c%d]"
|
||||
ITEM_MOD_AGILITY = "[MOD_AGILITY %c%d]"
|
||||
ITEM_MOD_STRENGTH = "[MOD_STRENGTH %c%d]"
|
||||
ITEM_MOD_INTELLECT = "[MOD_INTELLECT %c%d]"
|
||||
ITEM_MOD_SPIRIT = "[MOD_SPIRIT %c%d]"
|
||||
ITEM_MOD_STAMINA = "[MOD_STAMINA %c%d]"
|
||||
ITEM_RESIST_ALL = "[RESIST_ALL %c%d]"
|
||||
ITEM_RESIST_SINGLE = "[RESIST_SINGLE %c%d %s]"
|
||||
|
||||
ITEM_RANDOM_ENCHANT = "[ITEM_RANDOM_ENCHANT]"
|
||||
ITEM_ENCHANT_TIME_LEFT_DAYS = "[ENCH %s %d d]"
|
||||
ITEM_ENCHANT_TIME_LEFT_DAYS_P1 = "[ENCH %s %d dd]"
|
||||
ITEM_ENCHANT_TIME_LEFT_HOURS = "[ENCH %s %d h]"
|
||||
ITEM_ENCHANT_TIME_LEFT_HOURS_P1 = "[ENCH %s %d hh]"
|
||||
ITEM_ENCHANT_TIME_LEFT_MIN = "[ENCH %s %d m]"
|
||||
ITEM_ENCHANT_TIME_LEFT_SEC = "[ENCH %s %d s]"
|
||||
ITEM_SPELL_CHARGES = "[CHARGES %d]"
|
||||
ITEM_SPELL_CHARGES_P1 = "[CHARGES %d x]"
|
||||
DURABILITY_TEMPLATE = "[DURABILITY %d/%d]"
|
||||
-- ITEM_DURATION_* ships no _P1 twin, and neither does this stand-in.
|
||||
ITEM_DURATION_DAYS = "[DURATION %d d]"
|
||||
ITEM_DURATION_HOURS = "[DURATION %d h]"
|
||||
ITEM_DURATION_MIN = "[DURATION %d m]"
|
||||
ITEM_DURATION_SEC = "[DURATION %d s]"
|
||||
|
||||
ITEM_CLASSES_ALLOWED = "[CLASSES %s]"
|
||||
ITEM_RACES_ALLOWED = "[RACES %s]"
|
||||
ITEM_MIN_LEVEL = "[MIN_LEVEL %d]"
|
||||
ITEM_MIN_SKILL = "[MIN_SKILL %s %d]"
|
||||
ITEM_REQ_SKILL = "[REQ_SKILL %s]"
|
||||
ITEM_SPELL_KNOWN = "[ITEM_SPELL_KNOWN]"
|
||||
ITEM_SPELL_TRIGGER_ONUSE = "[ONUSE]"
|
||||
ITEM_SPELL_TRIGGER_ONEQUIP = "[ONEQUIP]"
|
||||
ITEM_SPELL_TRIGGER_ONPROC = "[ONPROC]"
|
||||
|
||||
ITEM_SET_NAME = "[SET_NAME %s %d/%d]"
|
||||
ITEM_SET_BONUS = "[SET_BONUS %s]"
|
||||
ITEM_SET_BONUS_GRAY = "[SET_BONUS_GRAY %d %s]"
|
||||
|
||||
ITEM_WRITTEN_BY = "[WRITTEN_BY %s]"
|
||||
ITEM_CREATED_BY = "|cff00ff00[CREATED_BY %s]|r"
|
||||
ITEM_OPENABLE = "[ITEM_OPENABLE]"
|
||||
ITEM_READABLE = "[ITEM_READABLE]"
|
||||
ITEM_UNSELLABLE = "[ITEM_UNSELLABLE]"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The shared fixture VM, with [`seed_item_strings`] already run — every test in this tree needs
|
||||
/// it, because a builder that resolves nothing draws nothing.
|
||||
fn script() -> UiScript {
|
||||
let mut s = bare_script();
|
||||
seed_item_strings(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// A full weapon view exercising most line families.
|
||||
fn axe() -> ItemTemplateView {
|
||||
ItemTemplateView {
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
use super::{lines_of, script};
|
||||
use crate::script::*;
|
||||
|
||||
/// The §22 SET block, byte-read: a blank gold line, the gold "name (owned/total)" header, the
|
||||
/// skill line (white/red) between header and the member ladder (cream when equipped / gray;
|
||||
/// in-flight names wait), a second blank, then the threshold bonuses SORTED ascending — green
|
||||
/// only when the skill requirement is met AND owned ≥ threshold — plus the ask-once for an
|
||||
/// unseen set id.
|
||||
/// The §22 SET block, byte-read: a blank gold line, the gold `ITEM_SET_NAME` header, the skill
|
||||
/// line (white/red) between header and the member ladder (cream when equipped / gray; in-flight
|
||||
/// names wait), a second blank, then the threshold bonuses SORTED ascending — green only when the
|
||||
/// skill requirement is met AND owned ≥ threshold — plus the ask-once for an unseen set id.
|
||||
///
|
||||
/// **The two bonus arms are two different keys** (`0x52e056..0x52e0d6`): an ACTIVE bonus formats
|
||||
/// `ITEM_SET_BONUS` = "Set: %s" with no count at all, and only an inactive one formats
|
||||
/// `ITEM_SET_BONUS_GRAY` = "(%d) Set: %s". Watching the (2) bonus move between them as the skill
|
||||
/// gate opens and closes is what these assertions are for.
|
||||
#[test]
|
||||
fn item_set_block_counts_and_colors() {
|
||||
let mut s = script();
|
||||
@@ -65,14 +69,16 @@ fn item_set_block_counts_and_colors() {
|
||||
-- in the reference: `0x530270` bails before its `inc [esi+0x31c]`).
|
||||
assert(TTTextLeft3:GetText() == " \n", "blank before the header, got "
|
||||
.. string.format("%q", tostring(TTTextLeft3:GetText())))
|
||||
assert(TTTextLeft4:GetText() == "Defias Leather (2/5)", "got " .. tostring(TTTextLeft4:GetText()))
|
||||
assert(TTTextLeft4:GetText() == "[SET_NAME Defias Leather 2/5]", "got " .. tostring(TTTextLeft4:GetText()))
|
||||
assert(TTTextLeft5:GetText() == " Defias Mark")
|
||||
assert(TTTextLeft8:GetText() == " Defias Boots", "in-flight member renders no line")
|
||||
assert(TTTextLeft9:GetText() == " \n", "blank before the bonuses, got "
|
||||
.. string.format("%q", tostring(TTTextLeft9:GetText())))
|
||||
assert(TTTextLeft10:GetText() == "(2) Set: Increases movement speed slightly.",
|
||||
-- Owned 2 of 5: the (2) bonus is ACTIVE and takes the countless ITEM_SET_BONUS; the
|
||||
-- (4) one is not and takes ITEM_SET_BONUS_GRAY, which is the arm that carries the count.
|
||||
assert(TTTextLeft10:GetText() == "[SET_BONUS Increases movement speed slightly.]",
|
||||
"thresholds sort ascending, got " .. tostring(TTTextLeft10:GetText()))
|
||||
assert(TTTextLeft11:GetText() == "(4) Set: +10 Attack Power.")
|
||||
assert(TTTextLeft11:GetText() == "[SET_BONUS_GRAY 4 +10 Attack Power.]")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -90,7 +96,7 @@ fn item_set_block_counts_and_colors() {
|
||||
let green = [0.0, 1.0, 0.0, 1.0];
|
||||
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||||
assert_eq!(
|
||||
color(&lines, "Defias Leather (2/5)"),
|
||||
color(&lines, "[SET_NAME Defias Leather 2/5]"),
|
||||
gold,
|
||||
"set name is gold"
|
||||
);
|
||||
@@ -105,12 +111,12 @@ fn item_set_block_counts_and_colors() {
|
||||
"unowned member is gray"
|
||||
);
|
||||
assert_eq!(
|
||||
color(&lines, "(2) Set: Increases movement speed slightly."),
|
||||
color(&lines, "[SET_BONUS Increases movement speed slightly.]"),
|
||||
green,
|
||||
"active bonus is green"
|
||||
);
|
||||
assert_eq!(
|
||||
color(&lines, "(4) Set: +10 Attack Power."),
|
||||
color(&lines, "[SET_BONUS_GRAY 4 +10 Attack Power.]"),
|
||||
gray,
|
||||
"inactive bonus is gray"
|
||||
);
|
||||
@@ -124,16 +130,20 @@ fn item_set_block_counts_and_colors() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(6303)
|
||||
assert(TTTextLeft5:GetText() == "Requires Leatherworking (250)",
|
||||
assert(TTTextLeft5:GetText() == "[MIN_SKILL Leatherworking 250]",
|
||||
"the skill line sits between header and members")
|
||||
assert(TTTextLeft6:GetText() == " Defias Mark")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Requires Leatherworking (250)"), red);
|
||||
assert_eq!(color(&lines, "[MIN_SKILL Leatherworking 250]"), red);
|
||||
// The gate also moves it onto the OTHER key: an inactive bonus is the counted spelling.
|
||||
assert_eq!(
|
||||
color(&lines, "(2) Set: Increases movement speed slightly."),
|
||||
color(
|
||||
&lines,
|
||||
"[SET_BONUS_GRAY 2 Increases movement speed slightly.]"
|
||||
),
|
||||
gray,
|
||||
"an unmet skill grays even a met threshold"
|
||||
);
|
||||
@@ -144,13 +154,13 @@ fn item_set_block_counts_and_colors() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(
|
||||
color(&lines, "Requires Leatherworking (250)"),
|
||||
color(&lines, "[MIN_SKILL Leatherworking 250]"),
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
);
|
||||
assert_eq!(
|
||||
color(&lines, "(2) Set: Increases movement speed slightly."),
|
||||
color(&lines, "[SET_BONUS Increases movement speed slightly.]"),
|
||||
green,
|
||||
"meeting the skill restores the green"
|
||||
"meeting the skill restores the green — and the countless key with it"
|
||||
);
|
||||
// An unseen set id records the ask-once.
|
||||
s.set_item_template(
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
//! The engine unit tooltip builder (decision 0274 P3, law per 0276): the level-line composition
|
||||
//! (four templates, the rank words, "??", Corpse, "Race Class (Player)"), the flag lines
|
||||
//! (four `TOOLTIP_UNIT_LEVEL*` keys, the `ELITE`/`BOSS` rank table, "??", `CORPSE`, "Race Class"
|
||||
//! over `PLAYER` — all seeded here as marked stand-ins, see [`seed_level_strings`]), the flag lines
|
||||
//! (PvP white / Skinnable red / Civilian green), the world-mouseover drive (default anchor +
|
||||
//! `UPDATE_MOUSEOVER_UNIT` recolor + the fade arm on loss), and the health-bar watcher.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A stand-in string table for the level line — the four `TOOLTIP_UNIT_LEVEL*` templates and the
|
||||
/// three word slots that fill them, **deliberately not the shipped wording**. What these tests
|
||||
/// establish is *which key* each slot combination reaches and what fills it, never what the
|
||||
/// sentence says (decision 2045, "assert the identifier, not the sentence"), and here that is not
|
||||
/// a formality: `TOOLTIP_UNIT_LEVEL_CLASS`'s enUS "Level %s %s" is word-for-word
|
||||
/// `FRIENDS_LEVEL_TEMPLATE`, `UNIT_TYPE_LEVEL_TEMPLATE` and `CHARACTER_SELECT_INFO`, and the bare
|
||||
/// template's "Level %s" is also `ITEM_LEVEL`, `LEVEL_GAINED` and `UNIT_LEVEL_TEMPLATE`. An
|
||||
/// assertion on the English would pass on all seven.
|
||||
fn seed_level_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
TOOLTIP_UNIT_LEVEL = "[LEVEL %s]"
|
||||
TOOLTIP_UNIT_LEVEL_CLASS = "[LEVEL_CLASS %s %s]"
|
||||
TOOLTIP_UNIT_LEVEL_TYPE = "[LEVEL_TYPE %s %s]"
|
||||
TOOLTIP_UNIT_LEVEL_CLASS_TYPE = "[LEVEL_CLASS_TYPE %s %s %s]"
|
||||
CORPSE = "[CORPSE]"
|
||||
PLAYER = "[PLAYER]"
|
||||
ELITE = "[ELITE]"
|
||||
BOSS = "[BOSS]"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn wolf() -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
@@ -21,10 +46,11 @@ fn wolf() -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The creature law: gold name, subtitle, "Level 10 Beast (Elite)", red Skinnable.
|
||||
/// The creature law: gold name, subtitle, the CLASS_TYPE level line, red Skinnable.
|
||||
#[test]
|
||||
fn creature_line_law() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
let mut u = wolf();
|
||||
u.subtitle = Some("Alpha".into());
|
||||
@@ -41,7 +67,7 @@ fn creature_line_law() {
|
||||
assert(tt:SetUnit("target") == 1, "SetUnit returns 1 on a live unit")
|
||||
assert(TTTextLeft1:GetText() == "Timber Wolf")
|
||||
assert(TTTextLeft2:GetText() == "Alpha")
|
||||
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft3:GetText() == "[LEVEL_CLASS_TYPE 10 Beast [ELITE]]", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft4:GetText() == "Skinnable")
|
||||
-- A RECOGNISED token naming nothing answers nil...
|
||||
assert(tt:SetUnit("party4") == nil, "a recognised but absent unit answers nil")
|
||||
@@ -94,6 +120,7 @@ fn unqueried_wolf() -> UnitState {
|
||||
#[test]
|
||||
fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_unit("target", Some(unqueried_wolf()));
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
@@ -111,7 +138,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
"the title is the GlobalString, got '" .. tostring(TTTextLeft1:GetText()) .. "'")
|
||||
-- The record's other lines are absent with it: no subtitle, and the level line takes the
|
||||
-- bare TOOLTIP_UNIT_LEVEL template because both the CLASS and TYPE slots are the record's.
|
||||
assert(TTTextLeft2:GetText() == "Level 10", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL 10]", "got " .. TTTextLeft2:GetText())
|
||||
-- The descriptor's own lines are NOT the record's and show anyway.
|
||||
assert(TTTextLeft3:GetText() == "Skinnable")
|
||||
"#,
|
||||
@@ -132,7 +159,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
assert(TT:SetUnit("target") == 1)
|
||||
assert(TTTextLeft1:GetText() == "Timber Wolf", "the answer replaced UNKNOWNOBJECT")
|
||||
assert(TTTextLeft2:GetText() == "Alpha")
|
||||
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft3:GetText() == "[LEVEL_CLASS_TYPE 10 Beast [ELITE]]", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft4:GetText() == "Skinnable")
|
||||
"#,
|
||||
)
|
||||
@@ -191,6 +218,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
#[test]
|
||||
fn faction_line_and_civilian_gate() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 30,
|
||||
@@ -217,7 +245,7 @@ fn faction_line_and_civilian_gate() {
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 20", "friendly creature: no type word; got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL 20]", "friendly creature: no type word; got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "Stormwind", "faction line before PvP; got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft4:GetText() == "PvP")
|
||||
assert(TTTextLeft5 == nil or TTTextLeft5:GetText() == nil, "friendly civilian shows NO Civilian line")
|
||||
@@ -243,7 +271,7 @@ fn faction_line_and_civilian_gate() {
|
||||
r#"
|
||||
TT:SetOwner(UF9, "ANCHOR_RIGHT")
|
||||
TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 20 Humanoid", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS 20 Humanoid]", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "PvP")
|
||||
assert(TTTextLeft4:GetText() == "Civilian", "hostile+grey+pvp civilian warns; got " .. TTTextLeft4:GetText())
|
||||
assert(TTTextLeft5:GetText() == "Leader")
|
||||
@@ -280,6 +308,7 @@ fn faction_line_and_civilian_gate() {
|
||||
#[test]
|
||||
fn level_line_variants() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 60,
|
||||
@@ -304,7 +333,7 @@ fn level_line_variants() {
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 32 Human Rogue (Player)", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE 32 Human Rogue [PLAYER]]", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "PvP")
|
||||
"#,
|
||||
)
|
||||
@@ -326,7 +355,7 @@ fn level_line_variants() {
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT")
|
||||
TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 3 Corpse", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS 3 [CORPSE]]", "got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -347,7 +376,7 @@ fn level_line_variants() {
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT")
|
||||
TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level ?? Demon (Boss)", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE ?? Demon [BOSS]]", "got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -367,7 +396,7 @@ fn level_line_variants() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level ?? Beast", "hostile 10-up, got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS ?? Beast]", "hostile 10-up, got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -376,7 +405,7 @@ fn level_line_variants() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 70 Beast", "unfriendly 10-up, got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS 70 Beast]", "unfriendly 10-up, got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -385,7 +414,7 @@ fn level_line_variants() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 70 Orc Shaman (Player)", "hostile player, got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE 70 Orc Shaman [PLAYER]]", "hostile player, got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -462,40 +462,25 @@ pub(in crate::script) fn duration_text(
|
||||
Some(template.replacen("%d", &n.to_string(), 1))
|
||||
}
|
||||
|
||||
/// The `_P1` pick behind `GetText(token, gender, n)`. **The rule is the bare token for exactly one
|
||||
/// and the `_P1` twin otherwise** — so a zero count takes the plural, which is why the lapsing
|
||||
/// second reads "0 second*s* remaining".
|
||||
/// [`crate::strings::plural`] for this module's callers — the `_P1` pick behind
|
||||
/// `GetText(token, gender, n)`, whose byte law (and the refuted `> 1` reading: `703c89` is a
|
||||
/// *signed* test, so a zero ordinal takes the plural arm and a lapsing aura reads "0 seconds
|
||||
/// remaining") now lives with the primitive rather than in a fourth private copy of it. Four
|
||||
/// copies of one rule is the shape decision 2045 wrote about; `fill` had eight.
|
||||
///
|
||||
/// **Byte-pinned** (wow-re §3-BUFF-PLURAL, `aff4df61`), which upgrades what this house had read
|
||||
/// twice by inference — `FriendsFrame.xml`'s `guildPlural` and `TradeSkillFrame.xml`'s
|
||||
/// `pluralAbbr`. The exe resolves nothing itself: `0x52fa50` snprintf's the bare key, then
|
||||
/// `0x703bf0` looks up the **Lua global** `GetText` (`703c43: ba c8 2c 87 00`) and pcalls it with
|
||||
/// (key, ordinal, gender), using what comes back as the printf format. The predicate lives in
|
||||
/// `LocaleProperties.lua`'s `GetPluralIndex` — singular iff `not ordinal or ordinal == 1` — and
|
||||
/// `> 1` is REFUTED: `703c89: 7d 07` is a *signed* test, so a zero ordinal is pushed as the number
|
||||
/// `0` (Lua truthy) and takes the plural arm; only a negative becomes nil. Had it mapped zero to
|
||||
/// nil the lapsing line would read "0 second remaining".
|
||||
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0`
|
||||
/// on both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS
|
||||
/// folds nil to no suffix.
|
||||
///
|
||||
/// We collapse that to Rust rather than route through a `GetText` global because this line is
|
||||
/// computed engine-side off the live clock, and because `LocaleProperties.lua` is a file this house
|
||||
/// does not transcribe.
|
||||
///
|
||||
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0` on
|
||||
/// both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS folds
|
||||
/// nil to no suffix.
|
||||
///
|
||||
/// **Falls back to the bare token when `_P1` is absent** — also byte-pinned: a nil `genderTag`
|
||||
/// sends `GetText` down its short arm to `getglobal(token)`, the singular. (When *both* are absent
|
||||
/// the reference renders an EMPTY line, off its pre-seeded `0x882748`; we render no line at all.
|
||||
/// The divergence is unreachable with shipped data — all eight of this family's keys ship — and off
|
||||
/// an install there is no string table to be faithful to.)
|
||||
fn plural_template(token: &str, n: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
if n != 1 {
|
||||
if let Some(p1) = get(&format!("{token}_P1")).filter(|s| !s.is_empty()) {
|
||||
return Some(p1);
|
||||
}
|
||||
}
|
||||
get(token).filter(|s| !s.is_empty())
|
||||
/// (When BOTH the twin and the bare token are absent the reference renders an EMPTY line, off its
|
||||
/// pre-seeded `0x882748`; we render no line at all. Unreachable with shipped data — all eight of
|
||||
/// the aura family's keys ship — and off an install there is no string table to be faithful to.)
|
||||
pub(in crate::script) fn plural_template(
|
||||
token: &str,
|
||||
n: u32,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
crate::strings::plural(token, Some(n), get)
|
||||
}
|
||||
|
||||
/// A colour-component argument — `lua_tonumber`'s coercion (a numeric string counts, `""` and
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
//!
|
||||
//! **The line law is BYTE-VERIFIED** — the 0274 §5 verdict on `0x52b650`'s emission order
|
||||
//! (wow-re `ui/scratch/tooltip-content-law.md`, §5-cross-checked; folded back 2026-07-10):
|
||||
//! every family's order, gate, and color pointer is the binary's, with the enUS text from the
|
||||
//! extracted GlobalStrings. Not yet built (feeds pending, laws recorded): the instance
|
||||
//! families (soulbound override, enchants, made-by, live durability, cooldown-remaining).
|
||||
//! every family's order, gate, and color pointer is the binary's, and every sentence is a KEY
|
||||
//! resolved off the player's own `GlobalStrings.lua` at render time (decision 2045). Not yet
|
||||
//! built (feeds pending, laws recorded): the instance families (soulbound override, enchants,
|
||||
//! made-by, live durability, cooldown-remaining).
|
||||
//! Residual INTERIMs cited inline: the dual-wield/off-hand proficiency exception
|
||||
//! (`0x5eab70`), the type cell's override red, the set-owned count source.
|
||||
//!
|
||||
@@ -30,8 +31,9 @@
|
||||
//! The sell-price money row is the byte-verified law: only a REAL-INSTANCE source (`SetBagItem`)
|
||||
//! while the merchant window is open and repair mode is off — the engine computes
|
||||
//! `SellPrice × stack` and fires the `OnTooltipAddMoney` script; FrameXML renders the coins
|
||||
//! (`SetTooltipMoney`). A zero sell price in that context prints `ITEM_UNSELLABLE`. Template
|
||||
//! sources (`SetMerchantItem`/`SetItemById`/quest rows) never show money, per the same law.
|
||||
//! (`SetTooltipMoney`). A zero sell price in that context prints `ITEM_UNSELLABLE`
|
||||
//! (`0x854a74`, pushed at `0x52e4a3`). Template sources (`SetMerchantItem`/`SetItemById`/quest
|
||||
//! rows) never show money, per the same law.
|
||||
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
@@ -586,8 +588,12 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
None => {
|
||||
// Template in flight — the slot view's own name holds the plate (the same
|
||||
// 0138 posture as SetBagItem's miss path).
|
||||
// The compare header is a key like every other sentence (2045); this
|
||||
// fallback path shows it for exactly the reason the full render does.
|
||||
if compare {
|
||||
append_line(lua, &this, ("Currently Equipped".into(), GRAY), None, false)?;
|
||||
if let Some(t) = crate::strings::global(lua, "CURRENTLY_EQUIPPED") {
|
||||
append_line(lua, &this, (t, GRAY), None, false)?;
|
||||
}
|
||||
}
|
||||
if let Some(name) = name {
|
||||
let color = if compare {
|
||||
@@ -693,8 +699,8 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
if merchant_open && !repairing {
|
||||
if v.sell_price > 0 {
|
||||
fire_add_money(lua, h, u64::from(v.sell_price) * u64::from(count));
|
||||
} else {
|
||||
append_line(lua, &this, ("No sell price".into(), WHITE), None, false)?;
|
||||
} else if let Some(t) = crate::strings::global(lua, "ITEM_UNSELLABLE") {
|
||||
append_line(lua, &this, (t, WHITE), None, false)?;
|
||||
}
|
||||
}
|
||||
arm_compare(lua, h, &v);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
//! The item tooltip's display vocabulary — the extracted enUS name tables (`INVTYPE_*`,
|
||||
//! subclass, damage school, `ITEM_MOD_*`, class/race lists) and the byte-verified color
|
||||
//! constants the render law paints with. Pure data; the law itself is [`super::render`].
|
||||
//! The item tooltip's display vocabulary — the builder's own **key** tables (`INVTYPE_*`,
|
||||
//! `ITEM_MOD_*`, `SPELL_SCHOOL%d_CAP`), the DBC-sourced subclass/class/race names, and the
|
||||
//! byte-verified color constants the render law paints with. Pure data; the law itself is
|
||||
//! [`super::render`], which resolves every key here against the player's own `GlobalStrings.lua`
|
||||
//! (decision 2045).
|
||||
//!
|
||||
//! **Keys, not sentences, and the difference is not cosmetic.** `INVTYPE_SHIELD` and
|
||||
//! `INVTYPE_WEAPONOFFHAND` both read "Off Hand" in enUS and are separately localizable
|
||||
//! everywhere else; a table that stored the English could not tell them apart, and this one had
|
||||
//! them collapsed into a single arm until 2045's sweep.
|
||||
//!
|
||||
//! **What stays a Rust literal here, and why it is not the same thing**: [`subclass_name`],
|
||||
//! [`CLASS_NAMES`] and [`RACE_NAMES`] name rows the reference reads out of DBC records (the
|
||||
//! `{class,subclass}` table `0xc0db90`, ChrClasses, ChrRaces), never out of `GlobalStrings.lua`.
|
||||
//! Those belong to the DBC-feed question, not to this one.
|
||||
|
||||
/// The client's 7-entry quality→color table (wow-re RF-0055, VERIFIED at `0xc0d3c8` behind
|
||||
/// `GetItemQualityColor 0x48dfb0`): Poor gray, Common white, Uncommon green, Rare blue, Epic
|
||||
@@ -30,33 +42,49 @@ pub(super) const GRAY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0,
|
||||
/// The owned set member's pale cream — byte-read `0xc0d368 = ffffff97` (writer `0x529050`).
|
||||
pub(super) const CREAM: [f32; 4] = [1.0, 1.0, 151.0 / 255.0, 1.0];
|
||||
|
||||
/// InventoryType → the slot line (the client's `INVTYPE_*` GlobalStrings, enUS). 0 (non-equip),
|
||||
/// 18 (bag), 27 (quiver) draw no slot line.
|
||||
pub(super) fn invtype_name(t: u32) -> Option<&'static str> {
|
||||
/// InventoryType → the slot line's GlobalString **key** — the builder's own 30-entry pointer
|
||||
/// table at `0x83ddb0`, indexed by `[record+0x2c]` directly (`0x52c103: mov ecx,[ecx*4+0x83ddb0]`,
|
||||
/// the read the law's §10 names), dumped entry by entry rather than matched by English.
|
||||
///
|
||||
/// **Index 0 and index 29 are the pre-seeded empty string `0x882748`** — a non-equip item and an
|
||||
/// out-of-range type name nothing, which is the `None` here. 18 (bag) and 27 (quiver) *do* have
|
||||
/// entries (`INVTYPE_BAG`/`INVTYPE_QUIVER`) but are unreachable: both are containers, and a
|
||||
/// container takes the CONTAINER_SLOTS line above this call instead.
|
||||
///
|
||||
/// **Four of these keys ship no value, and that is the reference's behaviour, not a gap.**
|
||||
/// `INVTYPE_AMMO` (24), `INVTYPE_THROWN` (25), `INVTYPE_RANGEDRIGHT` (26) and `INVTYPE_QUIVER`
|
||||
/// (27) are in the exe's table but absent from `GlobalStrings.lua`, so `FrameScript_GetText`
|
||||
/// hands the builder an empty left cell and an arrow shows only its type word. We had written
|
||||
/// "Projectile", "Thrown" and "Ranged" into those arms — three sentences 1.12 never shows, and
|
||||
/// exactly the invention class decision 2045 says a text-matching tripwire cannot catch.
|
||||
pub(super) fn invtype_key(t: u32) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
1 => "Head",
|
||||
2 => "Neck",
|
||||
3 => "Shoulder",
|
||||
4 => "Shirt",
|
||||
5 | 20 => "Chest",
|
||||
6 => "Waist",
|
||||
7 => "Legs",
|
||||
8 => "Feet",
|
||||
9 => "Wrist",
|
||||
10 => "Hands",
|
||||
11 => "Finger",
|
||||
12 => "Trinket",
|
||||
13 => "One-Hand",
|
||||
14 | 22 => "Off Hand",
|
||||
15 | 26 => "Ranged",
|
||||
16 => "Back",
|
||||
17 => "Two-Hand",
|
||||
19 => "Tabard",
|
||||
21 => "Main Hand",
|
||||
23 => "Held In Off-hand",
|
||||
24 => "Projectile",
|
||||
25 => "Thrown",
|
||||
28 => "Relic",
|
||||
1 => "INVTYPE_HEAD",
|
||||
2 => "INVTYPE_NECK",
|
||||
3 => "INVTYPE_SHOULDER",
|
||||
4 => "INVTYPE_BODY",
|
||||
5 => "INVTYPE_CHEST",
|
||||
6 => "INVTYPE_WAIST",
|
||||
7 => "INVTYPE_LEGS",
|
||||
8 => "INVTYPE_FEET",
|
||||
9 => "INVTYPE_WRIST",
|
||||
10 => "INVTYPE_HAND",
|
||||
11 => "INVTYPE_FINGER",
|
||||
12 => "INVTYPE_TRINKET",
|
||||
13 => "INVTYPE_WEAPON",
|
||||
14 => "INVTYPE_SHIELD",
|
||||
15 => "INVTYPE_RANGED",
|
||||
16 => "INVTYPE_CLOAK",
|
||||
17 => "INVTYPE_2HWEAPON",
|
||||
19 => "INVTYPE_TABARD",
|
||||
20 => "INVTYPE_ROBE",
|
||||
21 => "INVTYPE_WEAPONMAINHAND",
|
||||
22 => "INVTYPE_WEAPONOFFHAND",
|
||||
23 => "INVTYPE_HOLDABLE",
|
||||
24 => "INVTYPE_AMMO",
|
||||
25 => "INVTYPE_THROWN",
|
||||
26 => "INVTYPE_RANGEDRIGHT",
|
||||
28 => "INVTYPE_RELIC",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -90,29 +118,31 @@ pub(super) fn subclass_name(class: u32, sub: u32) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Damage school suffix for a non-physical damage line ("5 - 9 Fire Damage").
|
||||
pub(super) fn school_name(s: u32) -> Option<&'static str> {
|
||||
Some(match s {
|
||||
1 => "Holy",
|
||||
2 => "Fire",
|
||||
3 => "Nature",
|
||||
4 => "Frost",
|
||||
5 => "Shadow",
|
||||
6 => "Arcane",
|
||||
_ => return None,
|
||||
})
|
||||
/// A damage/resistance school's name key — `SPELL_SCHOOL%d_CAP`, the one string the builder
|
||||
/// composes at runtime rather than naming outright (`0x84e4cc`, pushed at `0x52c2a8` for the
|
||||
/// damage line and `0x52c8d1` for the resistance line; law §11/§16).
|
||||
///
|
||||
/// The index is the school itself: `SPELL_SCHOOL0_CAP` is "Physical" and 1..6 are
|
||||
/// Holy/Fire/Nature/Frost/Shadow/Arcane. **School 0 answers `None`** because the reference
|
||||
/// doesn't name it either — a physical weapon takes the school-less `DAMAGE_TEMPLATE` arm rather
|
||||
/// than printing the word.
|
||||
pub(super) fn school_key(s: u32) -> Option<String> {
|
||||
(1..=6).contains(&s).then(|| format!("SPELL_SCHOOL{s}_CAP"))
|
||||
}
|
||||
|
||||
/// Stat-mod type → its `ITEM_MOD_*` display name (vanilla types 0..7; 2 is unused).
|
||||
pub(super) fn stat_name(t: u32) -> Option<&'static str> {
|
||||
/// Stat-mod type → its `ITEM_MOD_*` key. Not a *name*: the key resolves to the whole line
|
||||
/// template, sign hole and all (`ITEM_MOD_AGILITY = "%c%d Agility"`), which is why 2 has no arm —
|
||||
/// the builder's 8-way jump table `0x52e510` skips it (law §15, keys byte-read at
|
||||
/// `0x52c6eb..0x52c777`).
|
||||
pub(super) fn stat_key(t: u32) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
0 => "Mana",
|
||||
1 => "Health",
|
||||
3 => "Agility",
|
||||
4 => "Strength",
|
||||
5 => "Intellect",
|
||||
6 => "Spirit",
|
||||
7 => "Stamina",
|
||||
0 => "ITEM_MOD_MANA",
|
||||
1 => "ITEM_MOD_HEALTH",
|
||||
3 => "ITEM_MOD_AGILITY",
|
||||
4 => "ITEM_MOD_STRENGTH",
|
||||
5 => "ITEM_MOD_INTELLECT",
|
||||
6 => "ITEM_MOD_SPIRIT",
|
||||
7 => "ITEM_MOD_STAMINA",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
//! The item tooltip's line law — [`render_view`], the byte-verified emission order of the
|
||||
//! client's shared renderer `0x52b650` (see the parent module doc for the law's provenance and
|
||||
//! the compare/red/SET summaries).
|
||||
//!
|
||||
//! **Every sentence here is a key, never a Rust string** (decision 2045). The builder names its
|
||||
//! keys outright — the census of the string pointers reachable from `[0x52b650, 0x52e600)` is
|
||||
//! where each one below came from, so a line's key is the *reference's* choice and not a match on
|
||||
//! English. That distinction is load-bearing: `ITEM_REQ_SKILL`, `LOCKED_WITH_ITEM`,
|
||||
//! `LOCKED_WITH_SPELL` and `LOCKED_WITH_SPELL_KNOWN` are all "Requires %s" in enUS and four
|
||||
//! different strings anywhere else.
|
||||
//!
|
||||
//! A key the player's `GlobalStrings.lua` does not carry emits **no line at all** — never an
|
||||
//! invented one. That is `duration_text`'s disposition and the reference's own: its
|
||||
//! `FrameScript_GetText` hands back the pre-seeded empty string and `AddLine` drops an empty row
|
||||
//! before it ever counts it.
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
|
||||
use crate::script::tooltip::append_line;
|
||||
use crate::script::tooltip::{append_line, duration_text, plural_template};
|
||||
use crate::script::{ItemTemplateView, Model};
|
||||
use crate::strings::{fill, Arg};
|
||||
|
||||
use super::names::*;
|
||||
|
||||
@@ -150,11 +163,23 @@ pub(super) fn render_view(
|
||||
let addw = |l: (String, [f32; 4])| append_line(lua, this, l, None, true);
|
||||
let add2 =
|
||||
|l: (String, [f32; 4]), r: (String, [f32; 4])| append_line(lua, this, l, Some(r), false);
|
||||
// The VM's own `GlobalStrings.lua`, off the player's patch chain — the one source of every
|
||||
// sentence below.
|
||||
let get = |key: &str| crate::strings::global(lua, key);
|
||||
// One line, named by KEY and filled from the template the install carries. A key the table
|
||||
// does not hold emits nothing: the reference's empty `FrameScript_GetText` return reaches
|
||||
// `AddLine`'s `0x530270`, which bails before it increments the line count.
|
||||
let keyed = |key: &str, args: &[Arg<'_>], color: [f32; 4], wrap: bool| -> mlua::Result<()> {
|
||||
match get(key) {
|
||||
Some(t) => append_line(lua, this, (fill(&t, args), color), None, wrap),
|
||||
None => Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
// Compare mode (the shopping tooltips): the gray CURRENTLY_EQUIPPED header (`[arg+0x18]≠0`)
|
||||
// and a WHITE name instead of the quality color (`[arg+0x14]≠0`) — both byte-verified.
|
||||
if compare {
|
||||
add(("Currently Equipped".into(), GRAY))?;
|
||||
keyed("CURRENTLY_EQUIPPED", &[], GRAY, false)?;
|
||||
}
|
||||
let name_color = if compare {
|
||||
WHITE
|
||||
@@ -175,25 +200,25 @@ pub(super) fn render_view(
|
||||
// rule, and the reference's own (its resolve callback repaints the tooltip; ours is the
|
||||
// container re-enter loop).
|
||||
if let Some(p) = inst.and_then(|i| i.petition.as_ref()) {
|
||||
let (title_fmt, creator_fmt) = if p.is_charter {
|
||||
("Guild Name: %s", "Guild Master: %s")
|
||||
let (title_key, creator_key) = if p.is_charter {
|
||||
("GUILD_CHARTER_TITLE", "GUILD_CHARTER_CREATOR")
|
||||
} else {
|
||||
("Petition: %s", "Created by %s")
|
||||
("PETITION_TITLE", "PETITION_CREATOR")
|
||||
};
|
||||
if !p.title.is_empty() {
|
||||
add((title_fmt.replacen("%s", &p.title, 1), WHITE))?;
|
||||
keyed(title_key, &[Arg::S(&p.title)], WHITE, false)?;
|
||||
}
|
||||
if let Some(owner) = p.owner.as_deref().filter(|o| !o.is_empty()) {
|
||||
add((creator_fmt.replacen("%s", owner, 1), WHITE))?;
|
||||
keyed(creator_key, &[Arg::S(owner)], WHITE, false)?;
|
||||
}
|
||||
}
|
||||
// ITEM_SIGNABLE (green) — Flags bit 0x2000 (petitions).
|
||||
if v.flags & 0x2000 != 0 {
|
||||
add(("<Right Click for Details>".into(), GREEN))?;
|
||||
keyed("ITEM_SIGNABLE", &[], GREEN, false)?;
|
||||
}
|
||||
// "Conjured Item" (Flags bit 0x2).
|
||||
// ITEM_CONJURED (Flags bit 0x2).
|
||||
if v.flags & 0x2 != 0 {
|
||||
add(("Conjured Item".into(), WHITE))?;
|
||||
keyed("ITEM_CONJURED", &[], WHITE, false)?;
|
||||
}
|
||||
// The bind line (§6, white, one line). Bonding `[record+0x194]` ∈ {1..5} is what decides
|
||||
// whether a line prints at ALL — a Bonding-0 item says nothing here however it is held.
|
||||
@@ -206,26 +231,28 @@ pub(super) fn render_view(
|
||||
// forever (B310, Frostshake): the template's `bonding` never changes when the item binds —
|
||||
// the instance's flag is the only thing that does.
|
||||
match v.bonding {
|
||||
4 | 5 => add(("Quest Item".into(), WHITE))?,
|
||||
1..=3 if inst.is_some_and(|i| i.already_bound) => add(("Soulbound".into(), WHITE))?,
|
||||
1 => add(("Binds when picked up".into(), WHITE))?,
|
||||
2 => add(("Binds when equipped".into(), WHITE))?,
|
||||
3 => add(("Binds when used".into(), WHITE))?,
|
||||
4 | 5 => keyed("ITEM_BIND_QUEST", &[], WHITE, false)?,
|
||||
1..=3 if inst.is_some_and(|i| i.already_bound) => {
|
||||
keyed("ITEM_SOULBOUND", &[], WHITE, false)?
|
||||
}
|
||||
1 => keyed("ITEM_BIND_ON_PICKUP", &[], WHITE, false)?,
|
||||
2 => keyed("ITEM_BIND_ON_EQUIP", &[], WHITE, false)?,
|
||||
3 => keyed("ITEM_BIND_ON_USE", &[], WHITE, false)?,
|
||||
_ => {}
|
||||
}
|
||||
match v.max_count {
|
||||
1 => add(("Unique".into(), WHITE))?,
|
||||
n if n > 1 => add((format!("Unique ({n})"), WHITE))?,
|
||||
1 => keyed("ITEM_UNIQUE", &[], WHITE, false)?,
|
||||
n if n > 1 => keyed("ITEM_UNIQUE_MULTIPLE", &[Arg::D(n.into())], WHITE, false)?,
|
||||
_ => {}
|
||||
}
|
||||
if v.start_quest != 0 {
|
||||
add(("This Item Begins a Quest".into(), WHITE))?;
|
||||
keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?;
|
||||
}
|
||||
// LOCKED (red) — suppressed once the INSTANCE carries UNLOCKED `0x4` (the law's "and the
|
||||
// item is not already unlocked"; the same bit the openable sub-gate reads). The key-item
|
||||
// "Requires %s" sub-line joins with the Lock.dbc resolve (the GO-locks follow-up).
|
||||
if v.lock_id != 0 && inst.is_none_or(|i| i.flags & 0x4 == 0) {
|
||||
add(("Locked".into(), RED))?;
|
||||
keyed("LOCKED", &[], RED, false)?;
|
||||
}
|
||||
// Slot | type — or, for a bag, the single CONTAINER_SLOTS line in the same seat. The type
|
||||
// cell is suppressed for cloaks (InventoryType 16) and displayFlags-hidden subclasses
|
||||
@@ -241,7 +268,10 @@ pub(super) fn render_view(
|
||||
if v.container_slots > 0 {
|
||||
add((format!("{} Slot Bag", v.container_slots), WHITE))?;
|
||||
} else {
|
||||
let slot = invtype_name(v.inventory_type);
|
||||
// The slot cell is the `0x83ddb0` key table's entry for this InventoryType, resolved here
|
||||
// — so the four types whose keys the string table does not carry (ammo, thrown,
|
||||
// ranged-right, quiver) draw no slot cell, exactly as the reference does.
|
||||
let slot = invtype_key(v.inventory_type).and_then(&get);
|
||||
let ty = if v.inventory_type == 16 || v.hide_subclass {
|
||||
None
|
||||
} else {
|
||||
@@ -264,11 +294,10 @@ pub(super) fn render_view(
|
||||
left_red = true;
|
||||
}
|
||||
match (slot, ty) {
|
||||
(Some(s), Some(t)) => add2(
|
||||
(s.into(), req_color(!left_red)),
|
||||
(t.into(), req_color(!right_red)),
|
||||
)?,
|
||||
(Some(s), None) => add((s.into(), req_color(!left_red)))?,
|
||||
(Some(s), Some(t)) => {
|
||||
add2((s, req_color(!left_red)), (t.into(), req_color(!right_red)))?
|
||||
}
|
||||
(Some(s), None) => add((s, req_color(!left_red)))?,
|
||||
// No slot name: the type stands alone and takes the hard-miss color (the
|
||||
// builder's single-cell fallback keeps flag-1).
|
||||
(None, Some(t)) => add((t.into(), req_color(!right_red)))?,
|
||||
@@ -282,7 +311,7 @@ pub(super) fn render_view(
|
||||
(min + 0.5).floor() as i64,
|
||||
(max + 0.5).floor() as i64
|
||||
);
|
||||
if let Some(s) = school_name(sch) {
|
||||
if let Some(s) = school_key(sch).and_then(|k| get(&k)) {
|
||||
dmg = format!("{dmg} {s}");
|
||||
}
|
||||
dmg.push_str(" Damage");
|
||||
@@ -293,7 +322,7 @@ pub(super) fn render_view(
|
||||
add((dmg, WHITE))?;
|
||||
}
|
||||
for &(emin, emax, esch) in v.damages.iter().skip(1).filter(|d| d.1 > 0.0) {
|
||||
let s = school_name(esch).unwrap_or("");
|
||||
let s = school_key(esch).and_then(|k| get(&k)).unwrap_or_default();
|
||||
let sep = if s.is_empty() { "" } else { " " };
|
||||
add((
|
||||
format!(
|
||||
@@ -312,17 +341,27 @@ pub(super) fn render_view(
|
||||
.filter(|d| d.1 > 0.0)
|
||||
.map(|&(a, b, _)| (a + b) * 0.5)
|
||||
.sum();
|
||||
add((
|
||||
format!("({:.1} damage per second)", f64::from(total) / speed),
|
||||
// The precision is DPS_TEMPLATE's own `%.1f`, not ours — a locale that respells it
|
||||
// gets its own number of decimals with no code change (law §12: "the print precision
|
||||
// is FRAMEXML-DATA").
|
||||
keyed(
|
||||
"DPS_TEMPLATE",
|
||||
&[Arg::F(f64::from(total) / speed)],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if v.armor > 0 {
|
||||
add((format!("{} Armor", v.armor), WHITE))?;
|
||||
keyed("ARMOR_TEMPLATE", &[Arg::D(v.armor.into())], WHITE, false)?;
|
||||
}
|
||||
if v.block > 0 {
|
||||
add((format!("{} Block", v.block), WHITE))?;
|
||||
keyed(
|
||||
"SHIELD_BLOCK_TEMPLATE",
|
||||
&[Arg::D(v.block.into())],
|
||||
WHITE,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// Stat mods (+N Stamina …) in the client's DISPLAY order — the `0x808e88` table (byte-read:
|
||||
// 4,3,7,5,6,1,0 then 8,9,2,10 + zero padding; the builder's outer loop walks the table,
|
||||
@@ -337,31 +376,41 @@ pub(super) fn render_view(
|
||||
if t != want || val == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = stat_name(t) else { continue };
|
||||
let sign = if val > 0 { '+' } else { '-' };
|
||||
add((format!("{sign}{} {name}", val.abs()), WHITE))?;
|
||||
// The `ITEM_MOD_*` template is the whole line, sign hole included
|
||||
// (`"%c%d Agility"`) — the sign is an argument, not a prefix we glue on.
|
||||
let Some(key) = stat_key(t) else { continue };
|
||||
let sign = if val > 0 { "+" } else { "-" };
|
||||
keyed(key, &[Arg::S(sign), Arg::D(val.abs().into())], WHITE, false)?;
|
||||
}
|
||||
}
|
||||
// Resistances: six equal nonzero values collapse to the ALL line; otherwise one line per
|
||||
// nonzero school with HOLY excluded from the singles loop (both byte-verified).
|
||||
const RESIST_NAMES: [&str; 6] = ["Holy", "Fire", "Nature", "Frost", "Shadow", "Arcane"];
|
||||
// nonzero school with HOLY excluded from the singles loop (both byte-verified). The six
|
||||
// fields are schools 1..6, so slot `i` names `SPELL_SCHOOL{i+1}_CAP` — the same `%d`-composed
|
||||
// key the damage line uses one block up.
|
||||
let first_res = v.resistances[0];
|
||||
if first_res != 0 && v.resistances.iter().all(|&r| r == first_res) {
|
||||
let sign = if first_res > 0 { '+' } else { '-' };
|
||||
add((
|
||||
format!("{sign}{} to All Resistances", first_res.abs()),
|
||||
let sign = if first_res > 0 { "+" } else { "-" };
|
||||
keyed(
|
||||
"ITEM_RESIST_ALL",
|
||||
&[Arg::S(sign), Arg::D(first_res.abs().into())],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
} else {
|
||||
for (i, &r) in v.resistances.iter().enumerate() {
|
||||
if r == 0 || i == 0 {
|
||||
continue; // Holy (i == 0) never prints singly in 1.12
|
||||
}
|
||||
let sign = if r > 0 { '+' } else { '-' };
|
||||
add((
|
||||
format!("{sign}{} {} Resistance", r.abs(), RESIST_NAMES[i]),
|
||||
let sign = if r > 0 { "+" } else { "-" };
|
||||
let school = school_key(i as u32 + 1)
|
||||
.and_then(|k| get(&k))
|
||||
.unwrap_or_default();
|
||||
keyed(
|
||||
"ITEM_RESIST_SINGLE",
|
||||
&[Arg::S(sign), Arg::D(r.abs().into()), Arg::S(&school)],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// **Line 17 — the enchant family** (wow-re `tooltip-content-law.md` §1-ENCHANT, byte-carved
|
||||
@@ -401,7 +450,7 @@ pub(super) fn render_view(
|
||||
// where the reference shows the rolled lines).
|
||||
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
|
||||
if no_id_source && !signable && v.random_property != 0 {
|
||||
add(("<Random enchantment>".into(), GREEN))?;
|
||||
keyed("ITEM_RANDOM_ENCHANT", &[], GREEN, false)?;
|
||||
}
|
||||
for e in enchant_slots {
|
||||
let color = match (e.slot < 2, e.negative) {
|
||||
@@ -414,14 +463,23 @@ pub(super) fn render_view(
|
||||
// ceil-vs-truncate split are [`enchant_time_left`]'s; its source is
|
||||
// `SMSG_ITEM_ENCHANT_TIME_UPDATE`, never the item's own duration field.
|
||||
let mut text = match e.remaining_ms {
|
||||
Some(ms) => enchant_time_left(&e.name, ms),
|
||||
// The countdown IS the line — `ITEM_ENCHANT_TIME_LEFT_MIN = "%s (%d min)"` carries
|
||||
// the enchant's own name in its first hole. Without that template there is no line
|
||||
// to compose: the reference printf's an empty format into a zeroed buffer and
|
||||
// `AddLine` drops the empty row, so a missing key skips the slot rather than falling
|
||||
// back to the bare name.
|
||||
Some(ms) => match enchant_time_left(&e.name, ms, &get) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
},
|
||||
None => e.name.clone(),
|
||||
};
|
||||
// " (N Charges)" — the slot's own charges dword through ITEM_SPELL_CHARGES, then the
|
||||
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`). Only an owned item object carries
|
||||
// charges; the session/inspect legs ship ids alone, so this is naturally absent there.
|
||||
if e.charges != 0 {
|
||||
text.push_str(&format!(" ({})", charges_phrase(e.charges)));
|
||||
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`), which is the engine's own format and
|
||||
// not a string table entry. Only an owned item object carries charges; the session/inspect
|
||||
// legs ship ids alone, so this is naturally absent there.
|
||||
if let Some(charges) = charges_phrase(e.charges, &get) {
|
||||
text.push_str(&format!(" ({charges})"));
|
||||
}
|
||||
add((text, color))?;
|
||||
}
|
||||
@@ -430,12 +488,20 @@ pub(super) fn render_view(
|
||||
// "durability (red iff broken==0)", the AddLine colour pointer `0xc0d390`, the same
|
||||
// red as the unmet-requirement lines).
|
||||
let color = if cur == 0 { RED } else { WHITE };
|
||||
add((format!("Durability {cur} / {max}"), color))?;
|
||||
keyed(
|
||||
"DURABILITY_TEMPLATE",
|
||||
&[Arg::D(cur.into()), Arg::D(max.into())],
|
||||
color,
|
||||
false,
|
||||
)?;
|
||||
} else if v.max_durability > 0 {
|
||||
add((
|
||||
format!("Durability {} / {}", v.max_durability, v.max_durability),
|
||||
let full = i64::from(v.max_durability);
|
||||
keyed(
|
||||
"DURABILITY_TEMPLATE",
|
||||
&[Arg::D(full), Arg::D(full)],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// **ITEM_DURATION `0x854bb4` — the item's own expiry countdown** (`0x52ce0d`, one of the four
|
||||
// sites that set the builder's `[ebp-0x38]` return, wow-re `tooltip-content-law.md` §E3's
|
||||
@@ -444,9 +510,20 @@ pub(super) fn render_view(
|
||||
// 23) — the numbered law list omits the line entirely, which is the gap decision 1933 names.
|
||||
// Formatted through the same `0x52fa50` ladder as the enchant countdown, with key prefix
|
||||
// `ITEM_DURATION`; unlike its siblings that prefix ships **no `_P1` plural twin**, so every
|
||||
// count reads "days"/"hrs" (`GlobalStrings.lua:2401-2404`).
|
||||
// count reads "days"/"hrs" (`GlobalStrings.lua:2401-2404`) — which `plural_template`'s
|
||||
// fall-back-to-the-bare-token arm produces without a special case.
|
||||
//
|
||||
// **This is `tooltip::duration_text` itself**, not a second copy of the ladder: the two had
|
||||
// drifted into separate implementations of the same four arms, and the local one always
|
||||
// emitted a line (an invented sentence off an install) where the shared one returns `None`.
|
||||
// The one seam is the width — the deadline is parked as `u64` milliseconds and the reference's
|
||||
// formatter takes a dword, so a timer past 49.7 days saturates rather than wrapping. No 1.12
|
||||
// item carries one; the longest shipped duration is a fortnight.
|
||||
if let Some(ms) = inst.and_then(|i| i.duration_ms) {
|
||||
add((duration_left(ms), WHITE))?;
|
||||
let ms = u32::try_from(ms).unwrap_or(u32::MAX);
|
||||
if let Some(line) = duration_text(ms, "ITEM_DURATION", true, &get) {
|
||||
add((line, WHITE))?;
|
||||
}
|
||||
}
|
||||
// Class/race lists — red when the player's own bit is absent (the usable ask).
|
||||
if v.allowable_class > 0
|
||||
@@ -459,7 +536,12 @@ pub(super) fn render_view(
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
let ok = req.class_id > 0 && v.allowable_class & (1 << (req.class_id - 1)) != 0;
|
||||
add((format!("Classes: {}", list.join(", ")), req_color(ok)))?;
|
||||
keyed(
|
||||
"ITEM_CLASSES_ALLOWED",
|
||||
&[Arg::S(&list.join(", "))],
|
||||
req_color(ok),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if v.allowable_race > 0 && (v.allowable_race & full_mask(&RACE_NAMES)) != full_mask(&RACE_NAMES)
|
||||
@@ -471,30 +553,48 @@ pub(super) fn render_view(
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
let ok = req.race_id > 0 && v.allowable_race & (1 << (req.race_id - 1)) != 0;
|
||||
add((format!("Races: {}", list.join(", ")), req_color(ok)))?;
|
||||
keyed(
|
||||
"ITEM_RACES_ALLOWED",
|
||||
&[Arg::S(&list.join(", "))],
|
||||
req_color(ok),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// ITEM_MIN_LEVEL prints only for RequiredLevel > 1 (byte-VERIFIED `0x52d2cf`: `cmp esi,0x1 /
|
||||
// jle skip`) — a level-1 requirement gates nothing a logged-in player could fail, so the
|
||||
// real client hides it on all the level-1 consumables that carry one.
|
||||
if v.required_level > 1 {
|
||||
add((
|
||||
format!("Requires Level {}", v.required_level),
|
||||
keyed(
|
||||
"ITEM_MIN_LEVEL",
|
||||
&[Arg::D(v.required_level.into())],
|
||||
req_color(req.level >= v.required_level),
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// The skill requirement is TWO keys, picked by whether a rank is asked for — `ITEM_MIN_SKILL`
|
||||
// = "Requires %s (%d)" and `ITEM_REQ_SKILL` = "Requires %s" (the builder's own pair at
|
||||
// `0x52d34e`/`0x52d355`). `ITEM_REQ_SKILL` is also what the required-SPELL line below takes
|
||||
// (`0x52d650`): three other 1.12 keys read "Requires %s" in enUS — the `LOCKED_WITH_*` trio —
|
||||
// and none of them is this line.
|
||||
if let Some(skill) = &v.required_skill_name {
|
||||
let have = req.skills.get(&v.required_skill).copied().unwrap_or(0);
|
||||
let line = if v.required_skill_rank > 0 {
|
||||
format!("Requires {skill} ({})", v.required_skill_rank)
|
||||
let color = req_color(have >= v.required_skill_rank.max(1));
|
||||
if v.required_skill_rank > 0 {
|
||||
let args = [Arg::S(skill), Arg::D(v.required_skill_rank.into())];
|
||||
keyed("ITEM_MIN_SKILL", &args, color, false)?;
|
||||
} else {
|
||||
format!("Requires {skill}")
|
||||
};
|
||||
add((line, req_color(have >= v.required_skill_rank.max(1))))?;
|
||||
keyed("ITEM_REQ_SKILL", &[Arg::S(skill)], color, false)?;
|
||||
}
|
||||
}
|
||||
if v.required_spell != 0 {
|
||||
if let Some(name) = &v.required_spell_name {
|
||||
add((format!("Requires {name}"), req_color(known_spell)))?;
|
||||
keyed(
|
||||
"ITEM_REQ_SKILL",
|
||||
&[Arg::S(name)],
|
||||
req_color(known_spell),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if let Some(rep) = &v.required_rep_line {
|
||||
@@ -513,31 +613,40 @@ pub(super) fn render_view(
|
||||
}
|
||||
// ITEM_SPELL_KNOWN — a taught spell the player already knows, UNCONDITIONALLY red.
|
||||
if taught_known {
|
||||
add(("Already known".into(), RED))?;
|
||||
keyed("ITEM_SPELL_KNOWN", &[], RED, false)?;
|
||||
}
|
||||
// Green trigger lines (Use:/Equip:/Chance on hit:), wrapped — the text is app-resolved (the
|
||||
// spell's name in P1; its $-substituted description via the token engine in P2).
|
||||
// Green trigger lines, wrapped — the text is app-resolved (the spell's name in P1; its
|
||||
// $-substituted description via the token engine in P2). The prefix is a key, joined to the
|
||||
// text by the engine's own `"%s %s"` literal `0x82ef08` (`0x52da7e`) — which is why the
|
||||
// shipped values ("Use:", "Equip:", "Chance on hit:") carry no trailing space of their own.
|
||||
for &(trigger, _, ref text) in &v.spell_triggers {
|
||||
let prefix = match trigger {
|
||||
0 | 5 => "Use: ",
|
||||
1 => "Equip: ",
|
||||
2 => "Chance on hit: ",
|
||||
let key = match trigger {
|
||||
0 | 5 => "ITEM_SPELL_TRIGGER_ONUSE",
|
||||
1 => "ITEM_SPELL_TRIGGER_ONEQUIP",
|
||||
2 => "ITEM_SPELL_TRIGGER_ONPROC",
|
||||
_ => continue,
|
||||
};
|
||||
addw((format!("{prefix}{text}"), GREEN))?;
|
||||
let Some(prefix) = get(key) else { continue };
|
||||
addw((format!("{prefix} {text}"), GREEN))?;
|
||||
}
|
||||
if v.charges > 0 {
|
||||
add((charges_phrase(v.charges as u32), WHITE))?;
|
||||
if let Some(charges) = charges_phrase(v.charges.max(0) as u32, &get) {
|
||||
add((charges, WHITE))?;
|
||||
}
|
||||
// The item-SET block (§22, ABOVE the compact cut), byte-read at the builder's
|
||||
// `0x52d8a0..0x52e0f5`: a blank gold line ([`SET_SPACER`]), the gold "name (owned/total)"
|
||||
// header, the set-level skill line (white, red when short), the member ladder (" name" —
|
||||
// pale-cream `0xc0d368` when equipped, gray otherwise; a member whose template is still in
|
||||
// flight waits — the app re-pushes the view as names land), a second blank, then the threshold
|
||||
// bonuses "(N) Set: text" sorted THRESHOLD-ASCENDING (the builder qsorts the slot indices
|
||||
// via `0x52e5c0` — never the DBC's stored order), green only when the skill requirement
|
||||
// is met (`0x5eaae0`) AND owned ≥ threshold. "Owned" counts EQUIPPED members on both
|
||||
// builder paths (the owner-unit 19-slot scan / the hyperlink walk with section mask 0x1).
|
||||
// bonuses sorted THRESHOLD-ASCENDING (the builder qsorts the slot indices via `0x52e5c0` —
|
||||
// never the DBC's stored order), green only when the skill requirement is met (`0x5eaae0`)
|
||||
// AND owned ≥ threshold. "Owned" counts EQUIPPED members on both builder paths (the
|
||||
// owner-unit 19-slot scan / the hyperlink walk with section mask 0x1).
|
||||
//
|
||||
// **The two bonus arms are two different KEYS, and this is where reading the byte law paid**
|
||||
// (`0x52e056..0x52e0d6`): the met arm formats `ITEM_SET_BONUS` = "Set: %s" with the text
|
||||
// alone, and only the unmet arm formats `ITEM_SET_BONUS_GRAY` = "(%d) Set: %s" with the
|
||||
// count. We had printed the counted form in both colours, so an ACTIVE set bonus read
|
||||
// "(3) Set: …" where 1.12 reads "Set: …".
|
||||
if let Some(set) = &set_view {
|
||||
let owned = set
|
||||
.members
|
||||
@@ -545,22 +654,31 @@ pub(super) fn render_view(
|
||||
.filter(|(id, _)| equipped.contains(id))
|
||||
.count();
|
||||
addw((SET_SPACER.into(), GOLD))?;
|
||||
add((
|
||||
format!("{} ({}/{})", set.name, owned, set.members.len()),
|
||||
keyed(
|
||||
"ITEM_SET_NAME",
|
||||
&[
|
||||
Arg::S(&set.name),
|
||||
Arg::D(owned as i64),
|
||||
Arg::D(set.members.len() as i64),
|
||||
],
|
||||
GOLD,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
let skill_met = set.required_skill == 0 || {
|
||||
let have = req.skills.get(&set.required_skill).copied().unwrap_or(0);
|
||||
have >= set.required_skill_rank
|
||||
};
|
||||
if let Some(skill) = &set.required_skill_name {
|
||||
let line = if set.required_skill_rank > 0 {
|
||||
format!("Requires {skill} ({})", set.required_skill_rank)
|
||||
let color = req_color(skill_met);
|
||||
if set.required_skill_rank > 0 {
|
||||
let args = [Arg::S(skill), Arg::D(set.required_skill_rank.into())];
|
||||
keyed("ITEM_MIN_SKILL", &args, color, false)?;
|
||||
} else {
|
||||
format!("Requires {skill}")
|
||||
};
|
||||
add((line, req_color(skill_met)))?;
|
||||
keyed("ITEM_REQ_SKILL", &[Arg::S(skill)], color, false)?;
|
||||
}
|
||||
}
|
||||
// The member ladder's two-space indent is the engine's own `" %s"` literal `0x854b14`,
|
||||
// not a string-table entry.
|
||||
for (id, name) in &set.members {
|
||||
let Some(name) = name else { continue };
|
||||
let color = if equipped.contains(id) { CREAM } else { GRAY };
|
||||
@@ -570,12 +688,12 @@ pub(super) fn render_view(
|
||||
let mut bonuses: Vec<&(u32, String)> = set.bonuses.iter().collect();
|
||||
bonuses.sort_by_key(|&&(threshold, _)| threshold);
|
||||
for &(threshold, ref text) in bonuses {
|
||||
let color = if skill_met && owned as u32 >= threshold {
|
||||
GREEN
|
||||
if skill_met && owned as u32 >= threshold {
|
||||
keyed("ITEM_SET_BONUS", &[Arg::S(text)], GREEN, true)?;
|
||||
} else {
|
||||
GRAY
|
||||
};
|
||||
addw((format!("({threshold}) Set: {text}"), color))?;
|
||||
let args = [Arg::D(threshold.into()), Arg::S(text)];
|
||||
keyed("ITEM_SET_BONUS_GRAY", &args, GRAY, true)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The compact/compare early-return (`0x52e14c`, `[arg+0x14]≠0`): everything below —
|
||||
@@ -601,11 +719,12 @@ pub(super) fn render_view(
|
||||
// "Made by" (gifts join with the wrap/unwrap arc).
|
||||
if inst.flags & 0x8 == 0 {
|
||||
if let Some(name) = &inst.creator {
|
||||
if inst.has_text {
|
||||
add((format!("Written by {name}"), WHITE))?;
|
||||
let key = if inst.has_text {
|
||||
"ITEM_WRITTEN_BY"
|
||||
} else {
|
||||
add((format!("|cff00ff00<Made by {name}>|r"), WHITE))?;
|
||||
}
|
||||
"ITEM_CREATED_BY"
|
||||
};
|
||||
keyed(key, &[Arg::S(name)], WHITE, false)?;
|
||||
}
|
||||
}
|
||||
// ITEM_OPENABLE / ITEM_READABLE — ONE line, openable wins outright (the `jmp 0x52e35d` past
|
||||
@@ -624,9 +743,9 @@ pub(super) fn render_view(
|
||||
&& ((v.flags & 0x4 != 0 && (v.lock_id == 0 || inst.flags & 0x4 != 0))
|
||||
|| (v.flags & 0x200 != 0 && inst.flags & 0x8 != 0));
|
||||
if openable {
|
||||
add(("<Right Click to Open>".into(), GREEN))?;
|
||||
keyed("ITEM_OPENABLE", &[], GREEN, false)?;
|
||||
} else if v.page_text != 0 || inst.has_text {
|
||||
add(("<Right Click to Read>".into(), GREEN))?;
|
||||
keyed("ITEM_READABLE", &[], GREEN, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -634,124 +753,122 @@ pub(super) fn render_view(
|
||||
/// A temporary enchant's line text — the name with its countdown, the reference's own bucket
|
||||
/// ladder (wow-re `tooltip-content-law.md` §E3 → `0x52fa50`, byte-verified): a runtime key
|
||||
/// `ITEM_ENCHANT_TIME_LEFT_<DAYS|HOURS|MIN|SEC>` chosen by the largest unit that fits, with the
|
||||
/// count taken as **ceil** in the day/hour/minute arms and **truncated** in the seconds arm. The
|
||||
/// day/hour keys carry a `_P1` plural twin in the shipped GlobalStrings; minutes and seconds do
|
||||
/// not, so those read "(1 min)" at one minute exactly as they read "(9 min)".
|
||||
/// count taken as **ceil** in the day/hour/minute arms and **truncated** in the seconds arm, and
|
||||
/// the `_P1` plural twin picked by [`plural_template`]'s byte-pinned rule. The day and hour keys
|
||||
/// ship a twin; minutes and seconds do not, so those read "(1 min)" at one minute exactly as they
|
||||
/// read "(9 min)".
|
||||
///
|
||||
/// Templates are the shipped enUS values (`Interface\FrameXML\GlobalStrings.lua:2406-2411`),
|
||||
/// inlined like every other string in this builder.
|
||||
fn enchant_time_left(name: &str, ms: u64) -> String {
|
||||
match time_bucket(ms) {
|
||||
(TimeBucket::Days, 1) => format!("{name} (1 day)"),
|
||||
(TimeBucket::Days, n) => format!("{name} ({n} days)"),
|
||||
(TimeBucket::Hours, 1) => format!("{name} (1 hour)"),
|
||||
(TimeBucket::Hours, n) => format!("{name} ({n} hrs)"),
|
||||
(TimeBucket::Min, n) => format!("{name} ({n} min)"),
|
||||
(TimeBucket::Sec, n) => format!("{name} ({n} sec)"),
|
||||
}
|
||||
/// **Two holes, which is why this cannot be `duration_text`**: the templates are
|
||||
/// `"%s (%d min)"` — the enchant's own name fills the first, the count the second. That name is
|
||||
/// the `SpellItemEnchantment` row's, DBC data rather than a string-table sentence, so it arrives
|
||||
/// as an argument. `None` = the family is absent from the install's string table, and the caller
|
||||
/// draws no line at all.
|
||||
fn enchant_time_left(name: &str, ms: u64, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
let (suffix, n) = time_bucket(ms);
|
||||
let template = plural_template(&format!("ITEM_ENCHANT_TIME_LEFT_{suffix}"), n, get)?;
|
||||
Some(fill(&template, &[Arg::S(name), Arg::D(i64::from(n))]))
|
||||
}
|
||||
|
||||
/// The item self-expiry line's text — the same `0x52fa50` bucket ladder as
|
||||
/// [`enchant_time_left`] (largest unit that fits; **ceil** in the day/hour/minute arms,
|
||||
/// **truncated** in the seconds arm), composed against key prefix `ITEM_DURATION`.
|
||||
/// `0x52fa50`'s bucket + count for the ENCHANT countdown — the key suffix and the number that
|
||||
/// fills it. The largest unit that fits wins (`>= 1 day` · `>= 1 h` · `>= 1 min` · else seconds);
|
||||
/// the count is **ceil** in the day/hour/minute arms (the `roundUp` argument, which all seven of
|
||||
/// the function's callers pass as 1) and plain **truncation** in the seconds arm.
|
||||
///
|
||||
/// The one difference from its siblings is the absence of a plural split: `ITEM_DURATION_*` ships
|
||||
/// no `_P1` twin (`GlobalStrings.lua:2401-2404`), so a one-day timer reads "Duration: 1 days"
|
||||
/// exactly as the reference's own string table spells it.
|
||||
fn duration_left(ms: u64) -> String {
|
||||
match time_bucket(ms) {
|
||||
(TimeBucket::Days, n) => format!("Duration: {n} days"),
|
||||
(TimeBucket::Hours, n) => format!("Duration: {n} hrs"),
|
||||
(TimeBucket::Min, n) => format!("Duration: {n} min"),
|
||||
(TimeBucket::Sec, n) => format!("Duration: {n} sec"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rung [`time_bucket`] picked — `0x52fa50`'s runtime key suffix, as a type.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum TimeBucket {
|
||||
Days,
|
||||
Hours,
|
||||
Min,
|
||||
Sec,
|
||||
}
|
||||
|
||||
/// `0x52fa50`'s bucket + count, shared by every countdown line the item builder emits (the
|
||||
/// temporary enchant, the item's own expiry, and — when it is fed — the cooldown remaining).
|
||||
/// The largest unit that fits wins (`>= 1 day` · `>= 1 h` · `>= 1 min` · else seconds); the count
|
||||
/// is **ceil** in the day/hour/minute arms (the `roundUp` argument, which all seven of the
|
||||
/// function's callers pass as 1) and plain **truncation** in the seconds arm.
|
||||
fn time_bucket(ms: u64) -> (TimeBucket, u64) {
|
||||
/// The same ladder [`crate::script::tooltip::duration_text`] walks — it stays separate only
|
||||
/// because that one fills a single-hole template and this family has two holes; every other
|
||||
/// countdown in the tooltips goes through `duration_text`.
|
||||
fn time_bucket(ms: u64) -> (&'static str, u32) {
|
||||
const SEC: u64 = 1_000;
|
||||
const MIN: u64 = 60 * SEC;
|
||||
const HOUR: u64 = 60 * MIN;
|
||||
const DAY: u64 = 24 * HOUR;
|
||||
match ms {
|
||||
_ if ms >= DAY => (TimeBucket::Days, ms.div_ceil(DAY)),
|
||||
_ if ms >= HOUR => (TimeBucket::Hours, ms.div_ceil(HOUR)),
|
||||
_ if ms >= MIN => (TimeBucket::Min, ms.div_ceil(MIN)),
|
||||
_ => (TimeBucket::Sec, ms / SEC),
|
||||
}
|
||||
let (suffix, n) = match ms {
|
||||
_ if ms >= DAY => ("DAYS", ms.div_ceil(DAY)),
|
||||
_ if ms >= HOUR => ("HOURS", ms.div_ceil(HOUR)),
|
||||
_ if ms >= MIN => ("MIN", ms.div_ceil(MIN)),
|
||||
_ => ("SEC", ms / SEC),
|
||||
};
|
||||
// The count is a dword in the reference; a timer long enough to overflow one does not exist.
|
||||
(suffix, u32::try_from(n).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` — "%d Charge" / "%d Charges" (`GlobalStrings.lua:2448-2449`, the `_P1`
|
||||
/// plural twin). One rule for both consumers: the standalone charges line (law line 21) and the
|
||||
/// enchant line's ` (…)` suffix (§E3).
|
||||
fn charges_phrase(n: u32) -> String {
|
||||
match n {
|
||||
1 => "1 Charge".into(),
|
||||
n => format!("{n} Charges"),
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin (`0x84e3b4`, pushed at `0x52cae8` for the
|
||||
/// enchant suffix and `0x52db61` for the standalone line). One rule for both consumers: the
|
||||
/// charges line (law line 21) and the enchant line's ` (…)` suffix (§E3).
|
||||
///
|
||||
/// Zero answers `None` — the reference never reaches this with a zero count, and neither
|
||||
/// consumer here has a line to draw without one.
|
||||
fn charges_phrase(n: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let template = plural_template("ITEM_SPELL_CHARGES", n, get)?;
|
||||
Some(fill(&template, &[Arg::D(i64::from(n))]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stand-in string table, **deliberately not the shipped wording** — what is under test is
|
||||
/// which key is reached and what fills it, never what the sentence says (decision 2045,
|
||||
/// "assert the identifier, not the sentence"). Every value carries angle brackets so a
|
||||
/// resolved line cannot be mistaken for a composed one.
|
||||
fn table(key: &str) -> Option<String> {
|
||||
Some(
|
||||
match key {
|
||||
"ITEM_ENCHANT_TIME_LEFT_DAYS" => "<%s :: %d D>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_DAYS_P1" => "<%s :: %d DD>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_HOURS" => "<%s :: %d H>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_HOURS_P1" => "<%s :: %d HH>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_MIN" => "<%s :: %d M>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_SEC" => "<%s :: %d S>",
|
||||
"ITEM_SPELL_CHARGES" => "<%d chg>",
|
||||
"ITEM_SPELL_CHARGES_P1" => "<%d chgs>",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The countdown's bucket ladder (wow-re §1-ENCHANT §E3 → `0x52fa50`): the largest unit that
|
||||
/// fits wins, the count is **ceil** in the day/hour/minute arms and **truncated** in seconds,
|
||||
/// and the day/hour arms have singular/plural twins while minutes and seconds do not.
|
||||
/// and the day/hour arms have `_P1` twins while minutes and seconds do not — so those two
|
||||
/// suffixes reach the same key at every count.
|
||||
#[test]
|
||||
fn enchant_countdown_buckets_and_rounding() {
|
||||
let t = |ms| enchant_time_left("Rockbiter", ms);
|
||||
// Seconds truncate: 1900 ms is "1 sec", not 2.
|
||||
assert_eq!(t(1_900), "Rockbiter (1 sec)");
|
||||
assert_eq!(t(59_999), "Rockbiter (59 sec)");
|
||||
let t = |ms| enchant_time_left("Rockbiter", ms, &table);
|
||||
// Seconds truncate: 1900 ms is 1, not 2.
|
||||
assert_eq!(t(1_900).as_deref(), Some("<Rockbiter :: 1 S>"));
|
||||
assert_eq!(t(59_999).as_deref(), Some("<Rockbiter :: 59 S>"));
|
||||
// Minutes ceil: one second past 4 minutes already reads 5.
|
||||
assert_eq!(t(60_000), "Rockbiter (1 min)");
|
||||
assert_eq!(t(241_000), "Rockbiter (5 min)");
|
||||
// Hours ceil, with the shipped plural spelling ("hrs", not "hours").
|
||||
assert_eq!(t(3_600_000), "Rockbiter (1 hour)");
|
||||
assert_eq!(t(3_600_001), "Rockbiter (2 hrs)");
|
||||
assert_eq!(t(60_000).as_deref(), Some("<Rockbiter :: 1 M>"));
|
||||
assert_eq!(t(241_000).as_deref(), Some("<Rockbiter :: 5 M>"));
|
||||
// Hours ceil, and only the non-singular count takes the `_P1` key.
|
||||
assert_eq!(t(3_600_000).as_deref(), Some("<Rockbiter :: 1 H>"));
|
||||
assert_eq!(t(3_600_001).as_deref(), Some("<Rockbiter :: 2 HH>"));
|
||||
// Days ceil.
|
||||
assert_eq!(t(86_400_000), "Rockbiter (1 day)");
|
||||
assert_eq!(t(86_400_001), "Rockbiter (2 days)");
|
||||
// Zero never reaches here (the app drops an expired timer), but it must not panic.
|
||||
assert_eq!(t(0), "Rockbiter (0 sec)");
|
||||
assert_eq!(t(86_400_000).as_deref(), Some("<Rockbiter :: 1 D>"));
|
||||
assert_eq!(t(86_400_001).as_deref(), Some("<Rockbiter :: 2 DD>"));
|
||||
// Zero never reaches here (the app drops an expired timer), but it must not panic — and
|
||||
// zero takes the PLURAL arm, which is `GetPluralIndex`'s rule, not a rounding accident.
|
||||
assert_eq!(t(0).as_deref(), Some("<Rockbiter :: 0 S>"));
|
||||
}
|
||||
|
||||
/// The item self-expiry line shares the enchant countdown's ladder and differs in exactly one
|
||||
/// way: `ITEM_DURATION_*` ships **no `_P1` plural twin**, so a one-unit count reads the same
|
||||
/// plural spelling as any other ("1 days", "1 hrs") — the shipped enUS strings say so
|
||||
/// (`GlobalStrings.lua:2401-2404`), and inventing a singular here would be our own invention,
|
||||
/// not the reference's.
|
||||
/// A string table that does not carry the family draws **no line**, never an invented one —
|
||||
/// the whole point of resolving by key rather than composing (2045).
|
||||
#[test]
|
||||
fn item_duration_shares_the_ladder_but_not_the_plural() {
|
||||
assert_eq!(duration_left(1_900), "Duration: 1 sec"); // seconds truncate
|
||||
assert_eq!(duration_left(59_999), "Duration: 59 sec");
|
||||
assert_eq!(duration_left(60_000), "Duration: 1 min"); // minutes ceil
|
||||
assert_eq!(duration_left(241_000), "Duration: 5 min");
|
||||
assert_eq!(duration_left(3_600_000), "Duration: 1 hrs"); // no singular twin
|
||||
assert_eq!(duration_left(3_600_001), "Duration: 2 hrs");
|
||||
assert_eq!(duration_left(86_400_000), "Duration: 1 days");
|
||||
assert_eq!(duration_left(1_209_600_000), "Duration: 14 days");
|
||||
fn a_missing_countdown_family_draws_nothing() {
|
||||
assert_eq!(enchant_time_left("Rockbiter", 60_000, &|_| None), None);
|
||||
assert_eq!(charges_phrase(3, &|_| None), None);
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin — one rule, both consumers (the standalone
|
||||
/// charges line and the enchant line's suffix).
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin — one rule, both consumers (the charges
|
||||
/// line and the enchant line's suffix). A zero count has no line either way.
|
||||
#[test]
|
||||
fn charges_phrase_picks_the_plural() {
|
||||
assert_eq!(charges_phrase(1), "1 Charge");
|
||||
assert_eq!(charges_phrase(5), "5 Charges");
|
||||
assert_eq!(charges_phrase(1, &table).as_deref(), Some("<1 chg>"));
|
||||
assert_eq!(charges_phrase(5, &table).as_deref(), Some("<5 chgs>"));
|
||||
assert_eq!(charges_phrase(0, &table), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,10 @@ pub(super) fn spell_view_of(lua: &Lua, spell_id: u32) -> Option<SpellTooltipView
|
||||
/// builder law, after the rank line), the "Next rank:" block, and the green learn hint.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct TalentLines {
|
||||
pub rank_line: String,
|
||||
/// `TOOLTIP_TALENT_RANK` = "Rank %d/%d", already filled by the caller off the player's own
|
||||
/// string table. `None` = the table does not carry the key, and the plate shows no rank row
|
||||
/// (decision 2045 — never an invented one).
|
||||
pub rank_line: Option<String>,
|
||||
pub reqs: Vec<String>,
|
||||
/// The next rank's spell id (0 = none) — asked from the spell store when its description
|
||||
/// hasn't landed yet, so the hover's re-enter completes the block.
|
||||
@@ -187,7 +190,9 @@ fn render_spell(
|
||||
// The talent head: "Rank r/m" (builder line 2, TOOLTIP_TALENT_RANK white) + the red
|
||||
// requirement lines while locked (position CONFIRMED, decision 0305 — TalentLines doc).
|
||||
if let Some(t) = talent {
|
||||
append_line(lua, this, (t.rank_line.clone(), WHITE), None, false)?;
|
||||
if let Some(rank) = &t.rank_line {
|
||||
append_line(lua, this, (rank.clone(), WHITE), None, false)?;
|
||||
}
|
||||
for req in &t.reqs {
|
||||
append_line(lua, this, (req.clone(), RED), None, true)?;
|
||||
}
|
||||
@@ -250,21 +255,22 @@ fn render_spell(
|
||||
let color = if aura { WHITE } else { GOLD };
|
||||
append_line(lua, this, (desc.clone(), color), None, true)?;
|
||||
}
|
||||
// The talent tail: the "Next rank:" block (TOOLTIP_TALENT_NEXT_RANK white + the next rank's
|
||||
// gold description) and the green learn hint (builder line 13, TOOLTIP_TALENT_LEARN).
|
||||
// The talent tail: the TOOLTIP_TALENT_NEXT_RANK header (white, `0x854a10` pushed at
|
||||
// `0x52b2cd`) over the next rank's gold description, and the green learn hint (builder
|
||||
// line 13, TOOLTIP_TALENT_LEARN `0x8549f8` at `0x52b362`). Both are keys into the player's
|
||||
// own string table (decision 2045); an install without them shows the description alone
|
||||
// rather than a sentence of ours.
|
||||
if let Some(t) = talent {
|
||||
if let Some(next) = &t.next_desc {
|
||||
append_line(lua, this, ("Next rank:".to_string(), WHITE), None, false)?;
|
||||
if let Some(header) = crate::strings::global(lua, "TOOLTIP_TALENT_NEXT_RANK") {
|
||||
append_line(lua, this, (header, WHITE), None, false)?;
|
||||
}
|
||||
append_line(lua, this, (next.clone(), GOLD), None, true)?;
|
||||
}
|
||||
if t.learn {
|
||||
append_line(
|
||||
lua,
|
||||
this,
|
||||
("Click to learn".to_string(), GREEN),
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
if let Some(hint) = crate::strings::global(lua, "TOOLTIP_TALENT_LEARN") {
|
||||
append_line(lua, this, (hint, GREEN), None, false)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The duration-remaining line (`SetPlayerBuff` only) is GOLD `0xffffd200` — the same gold as
|
||||
@@ -309,7 +315,9 @@ pub(super) fn set_spell_with_talent(
|
||||
None => {
|
||||
// The view hasn't landed: show the talent head alone (the ask is recorded; the
|
||||
// hover's re-enter repaints complete) — the spell channel's own fallback shape.
|
||||
append_line(lua, this, (talent.rank_line.clone(), WHITE), None, false)?;
|
||||
if let Some(rank) = &talent.rank_line {
|
||||
append_line(lua, this, (rank.clone(), WHITE), None, false)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
//! 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:
|
||||
//! - the LEVEL line, filled from three slots into one of the four `TOOLTIP_UNIT_LEVEL*` templates
|
||||
//! the player's own `GlobalStrings.lua` carries (decision 2045 — the keys are the builder's,
|
||||
//! read off `0x52a622`/`0x52a64d`/`0x52a682`/`0x52a6ac`, never matched on their English):
|
||||
//! 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);
|
||||
//! TYPE word for hostile/neutral creatures, `"Race Class"` for players, `CORPSE` when dead),
|
||||
//! and the type slot (the rank key table `{"", 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 +
|
||||
@@ -36,6 +38,7 @@ use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empt
|
||||
use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject};
|
||||
use super::{KindState, Model, UnitState};
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::strings::{fill, Arg};
|
||||
use crate::widget::FrameHandle;
|
||||
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
@@ -65,19 +68,31 @@ pub enum TooltipTint {
|
||||
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> {
|
||||
/// The rank word's GlobalString **key** — byte-verified `0x854158[]`, the pointer table the
|
||||
/// builder indexes by rank at `0x52a5bd`/`0x52a5d4`. Its entries are key names, not words: index 0
|
||||
/// and 4 are the pre-seeded empty string `0x882748`, 1 and 2 are both `ELITE` (rare-elite prints
|
||||
/// ELITE, rare prints nothing — there is no distinct "Rare Elite" word in 1.12's builder), 3 is
|
||||
/// `BOSS`.
|
||||
fn rank_key(rank: u32) -> Option<&'static str> {
|
||||
match rank {
|
||||
1 | 2 => Some("Elite"),
|
||||
3 => Some("Boss"),
|
||||
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 level line — three slots over the four `TOOLTIP_UNIT_LEVEL*` templates, each resolved off
|
||||
/// the player's own `GlobalStrings.lua` (decision 2045). The keys are the builder's own, pushed at
|
||||
/// `0x52a622` (`_CLASS_TYPE`), `0x52a64d` (`_CLASS`), `0x52a682` (`_TYPE`) and `0x52a6ac` (the
|
||||
/// bare one) — which matters, because `TOOLTIP_UNIT_LEVEL_CLASS`'s enUS "Level %s %s" is also the
|
||||
/// wording of `FRIENDS_LEVEL_TEMPLATE`, `UNIT_TYPE_LEVEL_TEMPLATE` and `CHARACTER_SELECT_INFO`,
|
||||
/// and the bare one collides with `ITEM_LEVEL`, `LEVEL_GAINED` and `UNIT_LEVEL_TEMPLATE`. Seven
|
||||
/// wrong keys are reachable by matching the English here; none by reading the builder.
|
||||
///
|
||||
/// `None` = the install's string table carries no template for the slot combination in hand, and
|
||||
/// the plate shows no level row rather than a composed one.
|
||||
fn level_line(lua: &Lua, u: &UnitState, player_level: u32) -> Option<String> {
|
||||
let get = |key: &str| crate::strings::global(lua, key);
|
||||
// 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 "??".
|
||||
@@ -92,7 +107,7 @@ fn level_line(u: &UnitState, player_level: u32) -> String {
|
||||
// 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())
|
||||
get("CORPSE")
|
||||
} else if u.is_player {
|
||||
match (&u.race, &u.class) {
|
||||
(Some(r), Some(c)) => Some(format!("{r} {c}")),
|
||||
@@ -105,18 +120,28 @@ fn level_line(u: &UnitState, player_level: u32) -> String {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The type slot: the rank word, or "Player" for players.
|
||||
// The type slot: the rank word, or PLAYER for players.
|
||||
let type_slot = if u.is_player {
|
||||
Some("Player".to_string())
|
||||
get("PLAYER")
|
||||
} else {
|
||||
rank_word(u.rank).map(str::to_string)
|
||||
rank_key(u.rank).and_then(&get)
|
||||
};
|
||||
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}"),
|
||||
}
|
||||
let (key, args): (&str, Vec<Arg<'_>>) = match (&class_slot, &type_slot) {
|
||||
(Some(c), Some(t)) => (
|
||||
"TOOLTIP_UNIT_LEVEL_CLASS_TYPE",
|
||||
vec![Arg::S(&level_text), Arg::S(c), Arg::S(t)],
|
||||
),
|
||||
(Some(c), None) => (
|
||||
"TOOLTIP_UNIT_LEVEL_CLASS",
|
||||
vec![Arg::S(&level_text), Arg::S(c)],
|
||||
),
|
||||
(None, Some(t)) => (
|
||||
"TOOLTIP_UNIT_LEVEL_TYPE",
|
||||
vec![Arg::S(&level_text), Arg::S(t)],
|
||||
),
|
||||
(None, None) => ("TOOLTIP_UNIT_LEVEL", vec![Arg::S(&level_text)]),
|
||||
};
|
||||
Some(fill(&get(key)?, &args))
|
||||
}
|
||||
|
||||
/// Render the unit tooltip for `token`'s current snapshot; returns whether the unit existed.
|
||||
@@ -160,13 +185,9 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
|
||||
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,
|
||||
)?;
|
||||
if let Some(level) = level_line(lua, &u, player_level) {
|
||||
append_line(lua, this, (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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::script::item_stats::item_link;
|
||||
use crate::script::Model;
|
||||
|
||||
use super::view::{
|
||||
build_groups, first_recipe_index, inv_slot_name, num_rows, present_inv_slots, recipe_at, rows,
|
||||
build_groups, first_recipe_index, inv_slot_token, num_rows, present_inv_slots, recipe_at, rows,
|
||||
select, selected_visible_index, set_collapsed, Row,
|
||||
};
|
||||
|
||||
@@ -439,8 +439,15 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let mut out = Vec::new();
|
||||
// Each word is its `0x84dd70` token resolved off the player's own string table
|
||||
// (decision 2045). A token the table does not carry answers the empty string rather
|
||||
// than being dropped: the list is POSITIONAL — `GetTradeSkillInvSlotFilter(index)`
|
||||
// indexes the same order — so a hole would shift every filter after it.
|
||||
for bit in present_inv_slots(&model) {
|
||||
out.push(Value::String(lua.create_string(inv_slot_name(bit))?));
|
||||
let word = inv_slot_token(bit)
|
||||
.and_then(|k| crate::strings::global(lua, k))
|
||||
.unwrap_or_default();
|
||||
out.push(Value::String(lua.create_string(word)?));
|
||||
}
|
||||
Ok(MultiValue::from_vec(out))
|
||||
})?,
|
||||
|
||||
@@ -677,12 +677,38 @@ fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A stand-in string table for the `0x84dd70` token family — **deliberately not the shipped
|
||||
/// wording**, because what these tests establish is *which token* each slot bit reaches, never
|
||||
/// what the word says (decision 2045, "assert the identifier, not the sentence"). Naming each
|
||||
/// value after its own key is the point: `SECONDARYHANDSLOT`, `INVTYPE_SHIELD` and
|
||||
/// `INVTYPE_WEAPONOFFHAND` all read "Off Hand" in enUS, so an assertion on the English could not
|
||||
/// tell a correct table from one wired to the item tooltip's family.
|
||||
fn seed_slot_tokens(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
HEADSLOT = "<HEADSLOT>"; NECKSLOT = "<NECKSLOT>"
|
||||
SHOULDERSLOT = "<SHOULDERSLOT>"; SHIRTSLOT = "<SHIRTSLOT>"
|
||||
CHESTSLOT = "<CHESTSLOT>"; WAISTSLOT = "<WAISTSLOT>"
|
||||
LEGSSLOT = "<LEGSSLOT>"; FEETSLOT = "<FEETSLOT>"
|
||||
WRISTSLOT = "<WRISTSLOT>"; HANDSSLOT = "<HANDSSLOT>"
|
||||
FINGER0SLOT = "<FINGER0SLOT>"; FINGER1SLOT = "<FINGER1SLOT>"
|
||||
TRINKET0SLOT = "<TRINKET0SLOT>"; TRINKET1SLOT = "<TRINKET1SLOT>"
|
||||
BACKSLOT = "<BACKSLOT>"; MAINHANDSLOT = "<MAINHANDSLOT>"
|
||||
SECONDARYHANDSLOT = "<SECONDARYHANDSLOT>"; RANGEDSLOT = "<RANGEDSLOT>"
|
||||
TABARDSLOT = "<TABARDSLOT>"; BAGSLOT = "<BAGSLOT>"
|
||||
NONEQUIPSLOT = "<NONEQUIPSLOT>"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The InvSlot filter: the vocabulary is the distinct slot words ascending by slot bit; an
|
||||
/// exclusive set drops every recipe on other slots AND any group that empties — header
|
||||
/// included.
|
||||
#[test]
|
||||
fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
seed_slot_tokens(&mut s);
|
||||
let mut st = state();
|
||||
// Wind Cloak is the one Back product (16 → bit 14); everything else stays Robe/Chest.
|
||||
st.recipes
|
||||
@@ -695,7 +721,7 @@ fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
assert_eq!(
|
||||
s.eval::<(String, String)>("return GetTradeSkillInvSlots()")
|
||||
.unwrap(),
|
||||
("Chest".to_string(), "Back".to_string()),
|
||||
("<CHESTSLOT>".to_string(), "<BACKSLOT>".to_string()),
|
||||
"ascending slot-bit order (4 before 14)"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -723,6 +749,7 @@ fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
#[test]
|
||||
fn one_hand_weapon_spans_both_hand_slots() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
seed_slot_tokens(&mut s);
|
||||
let mut st = two_recipe_state();
|
||||
st.recipes[0].product_inv_type = 13; // Bolt of Linen Cloth becomes a one-hand weapon
|
||||
s.set_trade_skill(Some(st));
|
||||
@@ -731,9 +758,10 @@ fn one_hand_weapon_spans_both_hand_slots() {
|
||||
s.eval::<(String, String, String)>("return GetTradeSkillInvSlots()")
|
||||
.unwrap(),
|
||||
(
|
||||
"Chest".to_string(),
|
||||
"Main Hand".to_string(),
|
||||
"Off Hand".to_string()
|
||||
"<CHESTSLOT>".to_string(),
|
||||
"<MAINHANDSLOT>".to_string(),
|
||||
// Not `INVTYPE_WEAPONOFFHAND`: this dropdown is the paper-doll family.
|
||||
"<SECONDARYHANDSLOT>".to_string()
|
||||
)
|
||||
);
|
||||
// Exclusive "Off Hand" (index 3): the weapon row survives, the robe row hides.
|
||||
|
||||
@@ -40,34 +40,46 @@ fn inv_slot_mask(inv_type: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The InvSlot dropdown's per-bit display word — the real client's 24-entry GlobalString-token
|
||||
/// table (`0x84dd70`, byte-VERIFIED — wow-re `tradeskill` TU-G §2), resolved to the tokens' enUS
|
||||
/// values (`GlobalStrings.lua`: `HEADSLOT = "Head"` … `NONEQUIPSLOT = "Not equippable."`). Bits
|
||||
/// 11/13/20-22 (FINGER1SLOT/TRINKET1SLOT/extra BAGSLOTs) are present in the table but unreachable
|
||||
/// through [`inv_slot_mask`] — kept for the full 24-entry fidelity.
|
||||
pub(super) fn inv_slot_name(bit: u32) -> &'static str {
|
||||
match bit {
|
||||
0 => "Head",
|
||||
1 => "Neck",
|
||||
2 => "Shoulders",
|
||||
3 => "Shirt",
|
||||
4 => "Chest",
|
||||
5 => "Waist",
|
||||
6 => "Legs",
|
||||
7 => "Feet",
|
||||
8 => "Wrist",
|
||||
9 => "Hands",
|
||||
10 | 11 => "Finger",
|
||||
12 | 13 => "Trinket",
|
||||
14 => "Back",
|
||||
15 => "Main Hand",
|
||||
16 => "Off Hand",
|
||||
17 => "Ranged",
|
||||
18 => "Tabard",
|
||||
19..=22 => "Bag",
|
||||
23 => "Not equippable.",
|
||||
_ => "",
|
||||
}
|
||||
/// The InvSlot dropdown's per-bit GlobalString **token** — the real client's 24-entry table
|
||||
/// (`0x84dd70`, byte-VERIFIED — wow-re `tradeskill` TU-G §2), dumped entry by entry. The caller
|
||||
/// resolves it against the player's own `GlobalStrings.lua` (decision 2045); these are the
|
||||
/// paper-doll `*SLOT` family, **not** the `INVTYPE_*` family the item tooltip's slot line uses,
|
||||
/// and that distinction is exactly why the word cannot be stored here: `SECONDARYHANDSLOT`,
|
||||
/// `INVTYPE_SHIELD` and `INVTYPE_WEAPONOFFHAND` are three separately-localizable strings that all
|
||||
/// read "Off Hand" in enUS.
|
||||
///
|
||||
/// Bits 11/13/20-22 (`FINGER1SLOT`/`TRINKET1SLOT`/the extra `BAGSLOT`s) are present in the table
|
||||
/// but unreachable through [`inv_slot_mask`] — kept for the full 24-entry fidelity.
|
||||
///
|
||||
/// **`None` is the out-of-range arm and is unreachable by construction**: the only caller walks
|
||||
/// [`present_inv_slots`], which enumerates bits 0..23, and the reference's table has an entry for
|
||||
/// every one of them. It resolves to the empty string at the call site, which is what
|
||||
/// `FrameScript_GetText` hands back for a token the table has no row for.
|
||||
pub(super) fn inv_slot_token(bit: u32) -> Option<&'static str> {
|
||||
Some(match bit {
|
||||
0 => "HEADSLOT",
|
||||
1 => "NECKSLOT",
|
||||
2 => "SHOULDERSLOT",
|
||||
3 => "SHIRTSLOT",
|
||||
4 => "CHESTSLOT",
|
||||
5 => "WAISTSLOT",
|
||||
6 => "LEGSSLOT",
|
||||
7 => "FEETSLOT",
|
||||
8 => "WRISTSLOT",
|
||||
9 => "HANDSSLOT",
|
||||
10 => "FINGER0SLOT",
|
||||
11 => "FINGER1SLOT",
|
||||
12 => "TRINKET0SLOT",
|
||||
13 => "TRINKET1SLOT",
|
||||
14 => "BACKSLOT",
|
||||
15 => "MAINHANDSLOT",
|
||||
16 => "SECONDARYHANDSLOT",
|
||||
17 => "RANGEDSLOT",
|
||||
18 => "TABARDSLOT",
|
||||
19..=22 => "BAGSLOT",
|
||||
23 => "NONEQUIPSLOT",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The InvSlot filter vocabulary: the set bits of the accumulated slot mask, ascending — the real
|
||||
|
||||
@@ -378,11 +378,23 @@ pub struct UnitState {
|
||||
///
|
||||
/// One resolver, so the verb and the plate can never disagree about what a unit whose name is
|
||||
/// still in flight is called (decisions 2002, 2040).
|
||||
///
|
||||
/// **Both halves of that tail are here, and both are load-bearing.** The lookup is the rule
|
||||
/// (decision 2045: the sentence is the install's, not ours); the literal beside it is the
|
||||
/// reference's own `0x860fa4`, which is what "a literal is legitimate only as a fallback beside a
|
||||
/// lookup" means. Spelled `globals().get::<String>` — the shape `benilla-app`'s
|
||||
/// `reference_strings` tripwire recognises as a resolver — so the fallback reads as the fallback
|
||||
/// it is rather than as undeclared drift. It answers the same three cases the `mlua::Value` read
|
||||
/// it replaces did: a non-empty string is returned, and both a missing global and a present-but-
|
||||
/// EMPTY one take `0x860fa4` (the binary tests the pointer and then its first byte).
|
||||
pub fn unknownobject(lua: &Lua) -> mlua::Result<mlua::String> {
|
||||
match lua.globals().get::<mlua::Value>("UNKNOWNOBJECT") {
|
||||
Ok(mlua::Value::String(s)) if !s.as_bytes().is_empty() => Ok(s),
|
||||
_ => lua.create_string("Unknown Being"),
|
||||
}
|
||||
// One expression, deliberately: `globals().get::<String>` is what the tripwire matches on,
|
||||
// and a line break inside it would hide this resolver from the walk again.
|
||||
let global = lua.globals().get::<String>("UNKNOWNOBJECT").ok();
|
||||
lua.create_string(match global.as_deref() {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => "Unknown Being",
|
||||
})
|
||||
}
|
||||
|
||||
/// The grey-band table `0x80ae98` (a byte-identical twin at `0x81dda8` drives the nameplate's
|
||||
|
||||
Reference in New Issue
Block a user