- the target-of-target frame and its option: 1.12's frame with its five display modes, fed one hop off the selected unit's own target field through the same read `/assist` runs, in four feeds (snapshot and transitions, the debuff buttons, the portrait booth, TargetUnit's click arm); ships off, as 1.12 does, on its own pair of saved globals - talents can be unlearned again: the trainer's `MSG_TALENT_WIPE_CONFIRM` had no handler since the wipe flow was deferred, so the gossip line closed the menu and did nothing — the question is an NPC session (walking away retracts it), the popup shows the cost on the StaticPopup money variant, Accept answers it behind the client's own purse check - Sprint's sparkles hang in the air behind you along the trail instead of collapsing into your torso: ride-vs-trail is the emitter's own file flag 0x10, not the host class — the finding wow-re returned eighteen days ago and nobody folded; the particle dump names the two flag bits it had been mis-reporting - `/sleep` takes both movement masks (the strictest posture), correcting the day's sit gate which let you lie down on a lake bottom while `/sit` was refused; a Lua-driven selection (TargetUnit on an attackable token) starts the swing exactly as a click does, through the one select path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
8.9 KiB
Rust
217 lines
8.9 KiB
Rust
//! Drives the REAL `assets/ui/CharacterFrame.xml` stat rows through the engine — the first test
|
||
//! that executes the paper doll's own `PaperDollFrame_Set*` Lua at all.
|
||
//!
|
||
//! That gap is why this file exists. The character sheet's whole buff-decomposition surface — the
|
||
//! green number, the red number, the `(base +x -y)` tooltip — sat broken from the day it shipped
|
||
//! (B165, B251) because the four `POSSTAT`/`NEGSTAT`/`RESISTANCEBUFFMODS*` descriptor arrays were
|
||
//! decoded as f32 when the wire carries INT, so every real buff arrived as a rounded `0` and every
|
||
//! row took its "nothing is modifying this" leg. Both gates were green throughout: the bindings'
|
||
//! own unit tests fed the snapshot directly, and nothing ever ran the Lua that colours the row.
|
||
//!
|
||
//! So the assertions here are deliberately at the *rendered string*: what `CharacterStatFrameNStatText`
|
||
//! and `MagicResTextN` actually carry after a repaint, colour escapes included. Three shapes per
|
||
//! family — untouched (plain), buffed (green), debuffed (red) — plus the tooltip's base arithmetic,
|
||
//! which is the guard for `UnitStat`'s first return being the RAW field rather than a pre-subtracted
|
||
//! one (decision 1397: the ref Lua subtracts `posBuff`/`negBuff` itself, so a pre-subtracted first
|
||
//! return deducts the buff twice — a defect the f32 bug was hiding).
|
||
|
||
use benilla_ui::script::{UiScript, UnitCombatStats, UnitState};
|
||
|
||
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
|
||
|
||
/// The character window's load prefix, in `assets/ui/benilla.toc` order. The .toc's own entry says
|
||
/// it "depends only on Fonts/UiPanels/GameTooltip"; `ItemButtonTemplate.xml` joins them because the
|
||
/// doll's equipment slots are `SetItemButton*` buttons.
|
||
const FILES: [&str; 6] = [
|
||
"Fonts.xml",
|
||
"ItemButtonTemplate.xml",
|
||
"MoneyFrame.xml",
|
||
"UiPanels.xml",
|
||
"GameTooltip.xml",
|
||
"CharacterFrame.xml",
|
||
];
|
||
|
||
fn load_ui(script: &UiScript) {
|
||
let dir = std::path::Path::new(UI_DIR);
|
||
// The app's provider shape: backslash paths, dir-relative, basename fallback.
|
||
let provider = |req: &str| -> Option<Vec<u8>> {
|
||
let norm = req.replace('\\', "/");
|
||
let base = norm.rsplit('/').next().unwrap_or(&norm);
|
||
std::fs::read(dir.join(&norm))
|
||
.or_else(|_| std::fs::read(dir.join(base)))
|
||
.ok()
|
||
};
|
||
for file in FILES {
|
||
let text = std::fs::read_to_string(dir.join(file))
|
||
.unwrap_or_else(|e| panic!("reading {file}: {e}"));
|
||
let doc =
|
||
benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("parsing {file}: {e}"));
|
||
let report = benilla_ui::loader::load(script, &doc, &provider);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"{file} loaded with errors: {:#?}",
|
||
report.errors
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The two colour escapes the sheet writes, as `Fonts.xml` defines them — asserted against the
|
||
/// rendered string rather than re-derived, so a palette change fails here loudly instead of
|
||
/// silently agreeing with itself.
|
||
const GREEN: &str = "|cff20ff20";
|
||
const RED: &str = "|cffff2020";
|
||
|
||
/// A level-60 body carrying one of each case: strength heavily geared (+105), agility cursed
|
||
/// (−12), stamina untouched, and the same three shapes across the resistance schools. The negative
|
||
/// halves are the wire words an **x86**-hosted server sends; an arm64 host saturates a debuff to a
|
||
/// flat `0` on the way out (decision 1397), which is exactly why the red leg has to be exercised
|
||
/// here and cannot be exercised against the local deploy.
|
||
fn stats() -> UnitCombatStats {
|
||
UnitCombatStats {
|
||
stats: [225, 68, 178, 34, 51],
|
||
stat_pos: [105, 0, 0, 4, 0],
|
||
stat_neg: [0, -12, 0, 0, 0],
|
||
// schools: [0] armor, then holy/fire/nature/frost/shadow/arcane.
|
||
resistances: [2965, 0, 65, 20, 0, 0, 0],
|
||
resistance_pos: [0, 0, 65, 0, 0, 0, 0],
|
||
resistance_neg: [0, 0, 0, -10, 0, 0, 0],
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
fn player() -> UnitState {
|
||
UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
race: Some("Human".into()),
|
||
class: Some("Warrior".into()),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Repaint the sheet the way an in-world `UNIT_STATS` does and read a row's rendered text back.
|
||
fn painted(script: &mut UiScript) {
|
||
script
|
||
.run("PaperDollFrame_SetStats() PaperDollFrame_SetResistances() PaperDollFrame_SetArmor()")
|
||
.expect("the paper doll's stat rows repaint");
|
||
}
|
||
|
||
fn text_of(script: &mut UiScript, region: &str) -> String {
|
||
script
|
||
.eval::<String>(&format!(r#"return getglobal("{region}"):GetText()"#))
|
||
.unwrap_or_else(|e| panic!("reading {region}: {e}"))
|
||
}
|
||
|
||
fn tooltip_of(script: &mut UiScript, frame: &str) -> String {
|
||
script
|
||
.eval::<String>(&format!(r#"return getglobal("{frame}").tooltip"#))
|
||
.unwrap_or_else(|e| panic!("reading {frame}.tooltip: {e}"))
|
||
}
|
||
|
||
fn seated() -> UiScript {
|
||
let mut script = UiScript::new().expect("a UI VM");
|
||
load_ui(&script);
|
||
script.set_unit("player", Some(player()));
|
||
script.set_player_combat_stats(Some(stats()));
|
||
script
|
||
}
|
||
|
||
#[test]
|
||
fn a_geared_stat_renders_green_and_its_tooltip_names_the_unbuffed_base() {
|
||
let mut s = seated();
|
||
painted(&mut s);
|
||
|
||
// Strength: 225 total off a 120 base, +105 from gear. Green number, and the tooltip's
|
||
// parenthesised base is the ONE subtraction the ref Lua performs (225 − 105 − 0 = 120).
|
||
let text = text_of(&mut s, "CharacterStatFrame1StatText");
|
||
assert!(
|
||
text.starts_with(GREEN) && text.contains("225"),
|
||
"a gear-boosted strength must render green, got {text:?}"
|
||
);
|
||
let tip = tooltip_of(&mut s, "CharacterStatFrame1");
|
||
assert!(
|
||
tip.contains("Strength 225") && tip.contains("(120") && tip.contains("+105"),
|
||
"the tooltip must read the total, the unbuffed base and the delta, got {tip:?}"
|
||
);
|
||
assert!(
|
||
!tip.contains("(15"),
|
||
"base 15 = 225 − 105 − 105: the buff was deducted twice, so UnitStat's first return is \
|
||
pre-subtracted. It must be the raw UNIT_FIELD_STAT — {tip:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_debuffed_stat_renders_red_and_an_untouched_one_stays_plain() {
|
||
let mut s = seated();
|
||
painted(&mut s);
|
||
|
||
let agility = text_of(&mut s, "CharacterStatFrame2StatText");
|
||
assert!(
|
||
agility.starts_with(RED) && agility.contains("68"),
|
||
"a cursed agility must render red, got {agility:?}"
|
||
);
|
||
// Stamina has neither half — the ref's `posBuff == 0 and negBuff == 0` leg: a bare number with
|
||
// no escape at all. This is the assertion the shipped bug would have PASSED, and it is here so
|
||
// a future over-correction that colours everything is caught.
|
||
let stamina = text_of(&mut s, "CharacterStatFrame3StatText");
|
||
assert_eq!(
|
||
stamina, "178",
|
||
"an unmodified stat carries no colour escape at all"
|
||
);
|
||
// Red wins over green when both halves are in play — the ref colours on `negBuff < 0` first.
|
||
s.set_player_combat_stats(Some(UnitCombatStats {
|
||
stat_pos: [105, 30, 0, 4, 0],
|
||
..stats()
|
||
}));
|
||
painted(&mut s);
|
||
let mixed = text_of(&mut s, "CharacterStatFrame2StatText");
|
||
assert!(
|
||
mixed.starts_with(RED),
|
||
"a stat with both a buff and a debuff renders RED, got {mixed:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resistance_rows_colour_by_which_half_is_bigger() {
|
||
let mut s = seated();
|
||
painted(&mut s);
|
||
|
||
// The five magic rows are the sheet's own school order (BENILLA_MAGICRES_IDS); resolve each
|
||
// row's school from the frame rather than assuming, so a row-order change cannot silently
|
||
// re-point these assertions.
|
||
let row_of = |s: &mut UiScript, school: i64| -> usize {
|
||
s.eval::<i64>(&format!(
|
||
"for i = 1, BENILLA_NUM_RESISTANCE_TYPES do \
|
||
if BENILLA_MAGICRES_IDS[i] == {school} then return i end \
|
||
end return 0"
|
||
))
|
||
.expect("the school→row map") as usize
|
||
};
|
||
|
||
let fire = row_of(&mut s, 2);
|
||
let nature = row_of(&mut s, 3);
|
||
assert!(fire > 0 && nature > 0, "fire and nature both have rows");
|
||
|
||
let fire_text = text_of(&mut s, &format!("MagicResText{fire}"));
|
||
assert!(
|
||
fire_text.starts_with(GREEN) && fire_text.contains("65"),
|
||
"a +65 fire resistance renders green, got {fire_text:?}"
|
||
);
|
||
let nature_text = text_of(&mut s, &format!("MagicResText{nature}"));
|
||
assert!(
|
||
nature_text.starts_with(RED),
|
||
"a nature school whose debuff outweighs its buff renders red, got {nature_text:?}"
|
||
);
|
||
}
|
||
|
||
/// Armor is the one row of this family the local server never decomposes — item armor lands in
|
||
/// `UNIT_FIELD_RESISTANCES[0]` itself, not in the buff split — so its plain-number leg is the live
|
||
/// shape and worth pinning as such.
|
||
#[test]
|
||
fn armor_with_no_buff_split_renders_plain() {
|
||
let mut s = seated();
|
||
painted(&mut s);
|
||
assert_eq!(text_of(&mut s, "CharacterArmorFrameStatText"), "2965");
|
||
}
|