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:
Sam
2026-09-08 16:19:46 +07:00
co-authored by Claude Fable 5.1
parent ecbdf2f716
commit fb52a2a98f
54 changed files with 3874 additions and 1958 deletions
+13 -1
View File
@@ -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,
+26 -9
View File
@@ -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();
+17 -32
View File
@@ -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);
}
}
+21 -13
View File
@@ -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);
+49 -28
View File
@@ -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 -28
View File
@@ -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
+16 -4
View File
@@ -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