names persist, the GM notice is a toast, and the target reads Dead

- the name cache persists to `benilla-config/cache/<realm>.tsv`, because the reference's `namecache.wdb` / `petnamecache.wdb` do and that is why it never sees the races we lost every time — unstabling a pet raised `Usage: SetText("text")` off an unresolved `UnitName("pet")`; the header carries magic, format, build, locale and realm and any mismatch discards the file (no checksum, no TTL: eviction is explicit only, via `SMSG_INVALIDATE_PLAYER`, decoded now and never sent by vmangos), negatives persist too, written on a 10 s debounce and at exit; a stable list also seeds the pet-name cache from the names it already carries
- `SMSG_NOTIFICATION` is the red UIErrorsFrame toast, not a chat line — `.gm on` printed twice because vmangos sends both a system message and a notification and both were rendered as chat; the stand-in dated from before the errors frame existed; `SMSG_AREA_TRIGGER_MESSAGE` moves with it on the yellow arm (its handler ends `xor edx,edx` — flag 0 — where the notification's is flag 1)
- the target frame's dead text is the GlobalString `DEAD` = "Dead" resolved through `text=` like the target-of-target frame, not the key typed into `SetText` in caps, and it takes `UnitIsConnected` so a disconnected player does not read as dead; a shipped-script sweep now refuses any key-shaped literal handed to a text sink; and the Help window is one click deep — Home's button opens the ticket text box, Auto-Unstuck sits beside it, and a ticket files as category 0, which a GM's queue names "Unknown" (a guard added days earlier refused 0 on reasoning from the enum rather than from the function that reads it)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:23:05 +07:00
co-authored by Claude Fable 5
parent 0cd0a28942
commit 55b40b5257
28 changed files with 1399 additions and 1120 deletions
@@ -86,36 +86,29 @@ fn open_ticket_args(
]
}
/// **The whole point of the feature, end to end.** The category id travels
/// `GMTicketCategory.dbc` → `GetGMTicketCategories()` → a `HelpFrameButton*` → the page route →
/// `HelpFrameOpenTicket.ticketType` → `NewGMTicket`'s wire value. Break any link and every ticket
/// files under the wrong heading, silently — nothing on screen looks wrong.
/// **The whole point of decision 1687: one click from Home to the text box.**
///
/// The category list and the per-category blurb page are gone, so this walks what is left — open
/// the window, press the one button, type, submit — and asserts the ticket goes out **uncategorised
/// (0)**. That last part is the assertion that actually pins 1687: 0 is what makes a GM's queue read
/// "Unknown" instead of a heading the player never chose.
#[test]
fn clicking_a_category_files_a_ticket_under_that_categorys_dbc_id() {
fn one_click_from_home_files_an_uncategorised_ticket() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"GMHome\")")
.unwrap();
// Opening the window is itself traffic: its OnShow calls `GetGMStatus()`. Drained here so the
// assertion below is about the CLICK and nothing else — the intents are one ordered queue, so
// setup traffic and the thing under test share it (decision 1673).
let _ = s.take_gm_ticket_intents();
s.run("ToggleHelpFrame()").unwrap();
let _ = s.take_gm_ticket_intents(); // the OnShow GetGMStatus
// Row 4 is Item, DBC id 4 — the row's LABEL and its stored id must be the same pair.
assert_eq!(
s.eval::<String>("return HelpFrameButton4Text:GetText()")
assert!(
s.eval::<bool>("return HelpFrameHome:IsVisible()").unwrap(),
"the window opens on Home"
);
s.run("HelpFrameHomeIssues:Click()").unwrap();
assert!(
s.eval::<bool>("return HelpFrameOpenTicket:IsVisible()")
.unwrap(),
"Item"
);
assert_eq!(
s.eval::<i64>("return HelpFrameButton4.ticketType").unwrap(),
4,
"the button carries the DBC id, not its row number"
"one click lands on the editor — no category page in between"
);
// Click it, take the page's action button through to the editor, and submit.
s.run("HelpFrameButton4:Click()").unwrap();
s.run("HelpFrameGeneralButton_OnClick(HelpFrameGeneralButton)")
.unwrap();
s.run("HelpFrameOpenTicketText:SetText(\"My sword vanished.\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
@@ -123,49 +116,71 @@ fn clicking_a_category_files_a_ticket_under_that_categorys_dbc_id() {
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Write(GmTicketWrite {
category: 4,
category: 0,
text: "My sword vanished.".into(),
is_new: true,
})],
"the clicked category's DBC id is what goes on the wire"
"a create files under 0 — \"Unknown\" in the GM's queue"
);
}
/// The ten shipped rows paint in DBC order, and row 2 routes to its OWN page rather than the
/// shared one — the single category in the table with a bespoke frame.
/// **Auto-Unstuck sits on Home now**, beside the ticket button — it used to be the Stuck category's
/// action button, three pages down, which is nowhere.
///
/// Clicked through the real button rather than by calling the handler, because the button's
/// existence on Home *is* the change.
#[test]
fn the_ten_categories_paint_in_dbc_order_and_harassment_has_its_own_page() {
let s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"GMHome\")")
.unwrap();
assert_eq!(
s.eval::<String>("return HelpFrameButton1Text:GetText()")
.unwrap(),
"Stuck"
fn auto_unstuck_is_a_button_on_home() {
let mut s = setup();
s.run("ToggleHelpFrame()").unwrap();
s.run("BenillaHelpFrameHomeUnstick:Click()").unwrap();
assert_eq!(s.take_stuck_casts(), 1);
assert!(
!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap(),
"and it closes the window"
);
assert_eq!(
s.eval::<String>("return HelpFrameButton10Text:GetText()")
.unwrap(),
"Character",
"all ten rows fit without scrolling"
}
/// **The two Home buttons do not overlap, and both fit their labels.**
///
/// The layout is ours (the reference had one button here), and its whole trick is that the left
/// button hangs by its TOPRIGHT and the right one off that fixed edge, so fitting them to their
/// text grows them *away* from the gutter. Nothing else in the suite would notice them landing on
/// top of each other: a script error inside `OnShow` is swallowed into the VM's error record, so a
/// fit that never ran still leaves every other test green.
#[test]
fn the_two_home_buttons_sit_side_by_side_without_overlapping() {
let mut s = setup();
s.run("ToggleHelpFrame()").unwrap();
s.resolve();
let edge = |frame: &str, edge: &str| {
s.eval::<f32>(&format!("return {frame}:Get{edge}()"))
.unwrap_or_else(|e| panic!("{frame}:Get{edge}(): {e}"))
};
let (ticket_l, ticket_r) = (
edge("HelpFrameHomeIssues", "Left"),
edge("HelpFrameHomeIssues", "Right"),
);
let (stuck_l, stuck_r) = (
edge("BenillaHelpFrameHomeUnstick", "Left"),
edge("BenillaHelpFrameHomeUnstick", "Right"),
);
s.run("HelpFrameButton2:Click()").unwrap();
assert!(
s.eval::<bool>("return HelpFrameHarassment:IsVisible()")
.unwrap(),
"category 2 routes to HelpFrameHarassment"
);
s.run("HelpFrameButton3:Click()").unwrap();
assert!(
s.eval::<bool>("return HelpFrameGeneral:IsVisible()")
.unwrap(),
"category 3 routes to the shared general page"
ticket_r <= stuck_l,
"the buttons overlap: ticket ends at {ticket_r}, unstick starts at {stuck_l}"
);
assert!(
!s.eval::<bool>("return HelpFrameHarassment:IsVisible()")
.unwrap(),
"and the previous page is hidden — one page at a time"
ticket_r > ticket_l && stuck_r > stuck_l,
"both buttons must have width: {ticket_l}..{ticket_r}, {stuck_l}..{stuck_r}"
);
// The fit ran: an unfitted button keeps its authored 250px, and "Auto-Unstuck" is nowhere near
// that wide. This is what catches an OnShow that silently errored out.
assert!(
stuck_r - stuck_l < 200.0,
"the unstick button was never fitted to its label (width {})",
stuck_r - stuck_l
);
}
@@ -216,35 +231,48 @@ fn an_open_ticket_turns_the_form_into_an_editor_and_a_zero_turns_it_back() {
);
}
/// The Submit button picks its verb from the window's own `hasTicket`, which is what the app must
/// not second-guess: an edit after an answer is an UPDATE, a submit before one is a CREATE.
/// The Submit button picks its verb from the window's own `hasTicket` — an edit after an answer is
/// an UPDATE, a submit before one is a CREATE — **and the two legs send different categories**.
///
/// That asymmetry is decision 1687's real content, and it is the half a suite would otherwise miss.
/// A create has nothing to go on and sends 0. An edit sends the server's own category straight
/// back, because `HandleGMTicketUpdateTextOpcode` overwrites the field with whatever arrives: send
/// 0 there and a GM who re-filed the ticket onto a real heading watches it drop to "Unknown" the
/// moment the player fixes a typo. Nothing on screen would ever show that.
#[test]
fn submit_sends_create_before_an_answer_and_update_after_one() {
fn a_create_files_under_zero_and_an_edit_gives_the_server_its_category_back() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
let _ = s.take_gm_ticket_intents(); // the OnShow GetGMStatus — see the category test
let _ = s.take_gm_ticket_intents(); // the OnShow GetGMStatus
// A stale `ticketType` must NOT leak into a create — the window has no picker, so a create is
// uncategorised no matter what is lying around on the frame.
s.run("HelpFrameOpenTicket.ticketType = 3 HelpFrameOpenTicketText:SetText(\"a\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
assert!(
matches!(
s.take_gm_ticket_intents().as_slice(),
[GmTicketIntent::Write(w)] if w.is_new
),
"no ticket known yet — this is a create"
);
let intents = s.take_gm_ticket_intents();
let [GmTicketIntent::Write(create)] = intents.as_slice() else {
panic!("expected exactly one write, got {intents:?}");
};
assert!(create.is_new, "no ticket known yet — this is a create");
assert_eq!(create.category, 0, "a create is uncategorised");
s.fire_event("UPDATE_TICKET", open_ticket_args(3, "a", 0.1, 0.2, 0.01));
// The server answers: this ticket is filed under 4 (Item) — a GM moved it there.
s.fire_event("UPDATE_TICKET", open_ticket_args(4, "a", 0.1, 0.2, 0.01));
s.run("HelpFrameOpenTicketText:SetText(\"a, still\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
let intents = s.take_gm_ticket_intents();
let [GmTicketIntent::Write(write)] = intents.as_slice() else {
let [GmTicketIntent::Write(edit)] = intents.as_slice() else {
panic!("expected exactly one write, got {intents:?}");
};
assert!(!write.is_new, "a ticket is known — this is an update");
assert_eq!(write.text, "a, still");
assert!(!edit.is_new, "a ticket is known — this is an update");
assert_eq!(edit.text, "a, still");
assert_eq!(
edit.category, 4,
"the edit must hand the GM's own category back, not overwrite it with 0"
);
}
/// **The queue gate.** `UPDATE_GM_STATUS(0)` takes the petition queue down, and asking for the
@@ -364,18 +392,6 @@ fn toggling_the_window_opens_it_and_asks_for_the_queue_status() {
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
}
/// The Auto-Unstuck button casts the Stuck spell and closes the window. It is the one control in
/// this window that does something other than talk about tickets.
#[test]
fn auto_unstuck_casts_and_closes() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(1)")
.unwrap();
s.run("HelpFrameUnstick_OnClick()").unwrap();
assert_eq!(s.take_stuck_casts(), 1);
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
}
/// **The retail-only text is gone from the Home page** (director's call, 2026-08-29).
///
/// The page is entirely GlobalStrings off the player's own chain, so three of its strings still
@@ -432,9 +448,11 @@ fn the_home_page_advertises_no_dead_retail_links() {
/// **The geometry oracle** (decision 0675): every element the transcription shares with the
/// reference file carries the reference's own `<AbsDimension>` numbers.
///
/// **Verified to fail**, as 0675 requires: nudging `TicketStatusFrame`'s width 208 → 209 makes it
/// report `TicketStatusFrame: ours [(209.0, 52.0), …] != ref [(208.0, 52.0), …]` and fail. It is a
/// guard, not a comfort.
/// **Verified to fail**, as 0675 requires, and re-verified after 1687 lowered `min_compared` from
/// 60 to 28: nudging `TicketStatusFrame`'s width 208 → 209 reports
/// `TicketStatusFrame: ours [(209.0, 52.0), …] != ref [(208.0, 52.0), …]` and fails. Re-checking
/// that after shrinking the floor is the point — a guard whose floor drops below what it actually
/// compares stops guarding and never says so.
#[test]
fn the_windows_geometry_matches_the_reference_file() {
let Some(reference) = super::framexml_diff::reference("HelpFrame.xml") else {
@@ -443,11 +461,16 @@ fn the_windows_geometry_matches_the_reference_file() {
/// Deliberate deviations, by REFERENCE name. Each earns its reason here; a tolerance would
/// let a real difference hide, so this is a list and stays a list.
const EXPECTED: &[&str] = &[
// The GM category list is the house FAUX scroll kit, not the reference's ScrollFrame, and
// the kit's shared trough replaces the three loose bar textures the reference hangs beside
// it (HelpFrame.xml's header, divergence 1). Same for the ticket editor's own trough.
"HelpFrameGMScrollFrame",
// The ticket editor's scroll frame carries the house kit's shared trough in place of the
// three loose bar textures the reference hangs beside it (HelpFrame.xml's header).
"HelpFrameOpenTicketScrollFrame",
// Home's button row is ours: the reference has one button centred under the text, we have
// two, so the left one hangs by its TOPRIGHT at -6 rather than centred at 0. Its SIZE still
// matches, which is why only the offset differs.
"HelpFrameHomeIssues",
];
super::framexml_diff::assert_geometry_matches("HelpFrame.xml", &reference, EXPECTED, 60);
// 1687 deleted the category list and the per-category page, and roughly seventy named elements
// went with them — hence 28 rather than 60. The floor still has to bite: it is what stops this
// guard quietly comparing nothing after the next window-shrinking change.
super::framexml_diff::assert_geometry_matches("HelpFrame.xml", &reference, EXPECTED, 28);
}
@@ -622,6 +622,114 @@ fn every_shipped_text_attribute_answers_against_the_real_global_strings() {
assert!(keys >= 23, "only {keys} key-shaped text= values swept");
}
/// **No shipped script hands a GlobalStrings KEY to a text sink as if it were the string.**
///
/// The defect this pins, twice now: `TargetFrame_CheckDead` did `deadText:SetText("DEAD")`, and
/// `DEAD` is not the word — it is `GlobalStrings.lua` l.898's key for `"Dead"`. So the target
/// frame wore **DEAD** in caps over every corpse, in every locale, until the director looked at
/// an Onyxia and said so. The same class shipped once before with `"CREATE_MACROS"` written
/// across the macro window's title bar (0983 → 0991).
///
/// The XML half of this class is already guarded twice: `loader::resolve_text` warns at load
/// whenever a **key-shaped** `text=` attribute resolves to nothing, and the sweep directly above
/// answers every one of them against the real `GlobalStrings.lua`. The **Lua** half had no guard
/// at all — `SetText` takes a string and cannot tell a key from a word — and that is the
/// half both escapes came through. This is that missing guard, as a shape test: a literal that is
/// all `[A-Z0-9_]` with an uppercase in it is `is_global_string_key`'s own predicate, and nothing
/// we would ever want a player to read.
///
/// Swept over the whole directory rather than the file that broke, because the next one will be a
/// different window. If a genuinely uppercase word ever needs to reach a sink, it goes through the
/// house's `gs(key, fallback)` helper (AuctionFrame/HelpFrame/MailFrame) or the XML `text=`
/// attribute — both resolve the global first, and both are what "faithful" means here.
#[test]
fn no_shipped_script_sets_a_global_string_key_as_display_text() {
/// `loader::is_global_string_key`'s predicate, restated here because it is private: at least
/// two characters, uppercase/digit/underscore only, and at least one uppercase.
fn key_shaped(s: &str) -> bool {
s.len() >= 2
&& s.chars().any(|c| c.is_ascii_uppercase())
&& s.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
// The literal after a text sink's open paren, if the call opens on this line at all. Prose is
// the noise here — the house documents its own history in comments, this very record included
// — so XML comment blocks are tracked across lines and Lua `--` lines are dropped.
fn sink_literals(line: &str) -> Vec<&str> {
let mut out = Vec::new();
for sink in [":SetText(", ":SetFormattedText(", ":SetButtonText("] {
let mut rest = line;
while let Some(at) = rest.find(sink) {
rest = &rest[at + sink.len()..];
let arg = rest.trim_start();
if let Some(body) = arg.strip_prefix('"') {
if let Some(end) = body.find('"') {
out.push(&body[..end]);
}
}
}
}
out
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut offenders: Vec<String> = Vec::new();
let mut swept = 0;
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "xml") {
continue;
}
let text = std::fs::read_to_string(&path).expect("read");
let mut in_comment = false;
for (n, raw) in text.lines().enumerate() {
// Strip whatever of this line sits inside an XML comment, carrying the state over.
let mut code = String::new();
let mut cur = raw;
loop {
if in_comment {
match cur.find("-->") {
Some(at) => {
in_comment = false;
cur = &cur[at + 3..];
}
None => break,
}
} else {
match cur.find("<!--") {
Some(at) => {
code.push_str(&cur[..at]);
in_comment = true;
cur = &cur[at + 4..];
}
None => {
code.push_str(cur);
break;
}
}
}
}
if code.trim_start().starts_with("--") {
continue; // a Lua comment line
}
for lit in sink_literals(&code) {
if key_shaped(lit) {
offenders.push(format!(
"{}:{}: SetText(\"{lit}\") — a GlobalStrings KEY, not the word",
path.file_name().unwrap().to_string_lossy(),
n + 1
));
}
}
}
swept += 1;
}
assert!(offenders.is_empty(), "{}", offenders.join("\n"));
// The sweep must never pass by finding nothing to sweep.
assert!(swept >= 40, "only {swept} xml files swept — sweep broke");
}
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
/// meant to cap a unit frame must sit at a strictly HIGHER frame level than that frame's status
/// bars — because frame level is the only key term above the draw layer, and the layer would
@@ -21,6 +21,12 @@ fn load_xml(s: &UiScript, file: &str) {
/// The unit frames' production load prefix (ui_script/mod.rs order): fonts + UIParent +
/// tooltip, then the dropdown kit + unit popups the frames' DropDown children initialize into.
fn load_unit_frames(s: &UiScript) {
// The app runs the real `Interface\FrameXML\GlobalStrings.lua` off the player's chain
// BEFORE any XML (`load_global_strings`); this stands in for it, with the one key these
// frames resolve at load time — `DEAD` l.898, which both dead-text FontStrings name in
// their `text=` attribute. Without it the loader's literal fallback would leave the KEY on
// screen, which is the bug this file guards below.
s.run(r#"DEAD = "Dead""#).unwrap();
load_xml(s, "Fonts.xml");
load_xml(s, "UIParent.xml");
// The bars' numerals machinery (decision 1082), which the manifest loads immediately ahead of
@@ -134,7 +140,7 @@ fn shipped_unit_frames_drive_end_to_end() {
and not TargetFramePowerBar:IsVisible()
and TargetFrameTextureFrameNameText:GetText() == "Young Wolf"
and TargetFrameTextureFrameLevelText:GetText() == "3"
and TargetFrameTextureFrameDeadText:GetText() == "" -- living target: no DEAD text
and not TargetFrameTextureFrameDeadText:IsShown() -- living target: no dead word
"#,
)
.unwrap();
@@ -1210,6 +1216,7 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
let hunter = |health: u32, power: u32, dead: bool| {
Some(UnitState {
exists: true,
is_connected: true, // CheckDead's second term — a feign is not a link-drop
name: Some("Nazriel".into()),
health,
max_health: 1500,
@@ -1230,7 +1237,7 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
r#"
local hb, pb = TargetFrameHealthBar, TargetFramePowerBar
return hb:GetValue() == 1200 and pb:GetValue() == 300
and TargetFrameTextureFrameDeadText:GetText() == ""
and not TargetFrameTextureFrameDeadText:IsShown()
"#,
)
.unwrap();
@@ -1254,11 +1261,17 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
.unwrap(),
"the mana bar empties, it does not disappear — UnitManaMax 0x5177e0 is ungated"
);
assert!(
s.eval::<bool>("return TargetFrameTextureFrameDeadText:IsShown() and true or false")
.unwrap(),
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
);
assert_eq!(
s.eval::<String>("return TargetFrameTextureFrameDeadText:GetText()")
.unwrap(),
"DEAD",
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
"Dead",
"the WORD is the GlobalString `DEAD` (l.898), never the key: a literal \"DEAD\" here \
is the caps bug the director caught on Onyxia"
);
assert!(
s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap(),
@@ -1273,7 +1286,7 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
r#"
return TargetFrameHealthBar:GetValue() == 1200
and TargetFramePowerBar:GetValue() == 300
and TargetFrameTextureFrameDeadText:GetText() == ""
and not TargetFrameTextureFrameDeadText:IsShown()
and not UnitIsDead("target")
"#,
)