fishing becomes whole: the bite, the line, the splash and the reel
- the whole loop plays: the line arcs rod tip to bobber, the bite splashes with its own sound, and clicking the bobber reels in - the verdicts read like 1.12: yellow "your fish got away" lines, the reel-in sound, the fishing loot window - "You feel rested." prints on the transition, rest states come from the client's own Exhaustion table, and level 60 trades the XP strip for the max-level rail - the Skills pane lists what the real client lists: the hidden rows, the sort, gray numberless single-rank lines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,8 +24,8 @@ fn shipped_action_bar_drives_end_to_end() {
|
||||
);
|
||||
if file == "ActionBar.xml" {
|
||||
assert_eq!(
|
||||
report.frames, 33,
|
||||
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
|
||||
report.frames, 34,
|
||||
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,25 +689,33 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
|
||||
name: "Defense".into(),
|
||||
value: 12,
|
||||
max: 60,
|
||||
modifier: 0,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id: 6,
|
||||
category_name: "Weapon Skills".into(),
|
||||
category_order: 1,
|
||||
description: "Defensive expertise.".into(),
|
||||
abandonable: false,
|
||||
mono: false,
|
||||
},
|
||||
SkillEntry {
|
||||
skill_id: 164,
|
||||
name: "Blacksmithing".into(),
|
||||
value: 62,
|
||||
max: 75,
|
||||
modifier: 0,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id: 11,
|
||||
category_name: "Professions".into(),
|
||||
category_order: 2,
|
||||
description: "Working with metals.".into(),
|
||||
// The real 5875 split: primary professions carry SkillRaceClassInfo 0x20.
|
||||
abandonable: true,
|
||||
mono: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
|
||||
// Open the bag and the loot window; drain the open kits (not under test here).
|
||||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![LootRow {
|
||||
item_id: 0,
|
||||
name: Some("Wool Cloth".into()),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! action-button machinery. Same file, two concerns: the strip along the bar's top is the
|
||||
//! player-progress readout; the buttons are the input surface.
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
|
||||
|
||||
/// The XP bar's load set: the manifest prefix it actually needs — fonts, UIParent, the
|
||||
/// tooltip, `TextStatusBar.xml` (the numerals machinery `BenillaExpBar_OnLoad` wires in),
|
||||
@@ -101,7 +101,8 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.resolve();
|
||||
|
||||
// The binding trio underneath (the app-feed shape the XML consumes).
|
||||
// The binding trio underneath (the app-feed shape the XML consumes) — the byte-verified
|
||||
// contract (wow-re rested-xp-bindings.md, decision 1087).
|
||||
let (id, name, mult) = s
|
||||
.eval::<(i64, String, f64)>("return GetRestState()")
|
||||
.unwrap();
|
||||
@@ -109,7 +110,7 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
|
||||
Some(1400),
|
||||
"the pool is served ×2 — bar-XP, not wire units"
|
||||
"the pool × Exhaustion.dbc row 1's factor (2.0) — bar-XP, not wire units"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return IsResting()").unwrap(),
|
||||
@@ -151,6 +152,33 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
.unwrap();
|
||||
assert!(ok, "a dry pool hides the tick and returns the purple bar");
|
||||
|
||||
// The nil law is the BYTE's, not the pool's (0x48d3f0's `dec/jne`, decision 1087): a normal-
|
||||
// state player with a nonzero pool — vmangos's 0 < bonus ≤ 10 hysteresis window — still reads
|
||||
// nil, and a rested player with a drained pool reads the NUMBER 0 (the tick then parks at the
|
||||
// bar's current fill). An unmapped byte (0 here) is the binary's nil-triple fail path.
|
||||
s.set_rest_state(2, 5, false);
|
||||
s.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
|
||||
None,
|
||||
"normal state hides the pool even while it holds a remnant"
|
||||
);
|
||||
s.set_rest_state(1, 0, true);
|
||||
s.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
|
||||
Some(0),
|
||||
"rested with a drained pool is the number 0, not nil"
|
||||
);
|
||||
s.set_rest_state(0, 0, false);
|
||||
assert!(
|
||||
s.eval::<bool>("local a, b, c = GetRestState() return a == nil and b == nil and c == nil")
|
||||
.unwrap(),
|
||||
"an unmapped byte is the binary's (nil, nil, nil) fail path"
|
||||
);
|
||||
s.set_rest_state(2, 0, false);
|
||||
s.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
|
||||
// A pool past the level's end (6000×2 span from 1000/10000): off the strip → hidden tick,
|
||||
// still rested blue (the ref's exhaustionTickSet > width branch).
|
||||
s.set_rest_state(1, 6000, false);
|
||||
@@ -172,6 +200,57 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The max-level rail (decision 1094, ref `ReputationWatchBar_Update`'s no-watched-faction arm):
|
||||
/// at MAX_PLAYER_LEVEL the XP strip and its tick give way to the flat brass rail; below it the
|
||||
/// strip is back. Walked over a live ding — 59 → 60 via `PLAYER_LEVEL_UP`'s arg1, the value the
|
||||
/// ref reads (not the level field, which may not have landed yet). The tick's own handler runs
|
||||
/// first (it loaded first) and re-shows the tick off the rested pool; the rail's handler then
|
||||
/// hides it — the ref's own load order, ending hidden.
|
||||
#[test]
|
||||
fn the_max_level_rail_replaces_the_xp_bar_at_60() {
|
||||
let mut s = exp_bar_harness();
|
||||
s.set_player_xp(1000, 10000);
|
||||
s.set_rest_state(1, 700, true);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 59,
|
||||
..UnitState::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.resolve();
|
||||
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
return BenillaExpBar:IsShown() and not BenillaMaxLevelBar:IsShown()
|
||||
and BenillaExhaustionTick:IsShown()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ok,
|
||||
"below 60 the strip and tick show, the rail stays hidden"
|
||||
);
|
||||
|
||||
s.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(60)]);
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
return not BenillaExpBar:IsShown() and BenillaMaxLevelBar:IsShown()
|
||||
and not BenillaExhaustionTick:IsShown()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ok,
|
||||
"at 60 the rail replaces the strip and the tick goes with it"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The hover numerals (decision 1082, ref TextStatusBar.lua over `MainMenuBarExpText`): entering
|
||||
/// the strip shows "curr / max" centered on the bar, leaving hides it (the lockShow refcount's
|
||||
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
|
||||
|
||||
@@ -243,6 +243,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
|
||||
s.set_container(0, Some(backpack()));
|
||||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![LootRow {
|
||||
item_id: 0,
|
||||
name: Some("Wool Cloth".into()),
|
||||
|
||||
@@ -63,6 +63,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
|
||||
|
||||
fn coin_and_two_items() -> LootState {
|
||||
LootState {
|
||||
fishing: false,
|
||||
rows: vec![
|
||||
LootRow {
|
||||
item_id: 0,
|
||||
@@ -219,6 +220,7 @@ fn shipped_loot_frame_drives_end_to_end() {
|
||||
|
||||
// LOOT_UPDATE with the coin row gone (looted) repaints to two rows, keeping the window open.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: coin_and_two_items().rows[1..].to_vec(),
|
||||
}));
|
||||
s.fire_event("LOOT_UPDATE", vec![]);
|
||||
@@ -272,7 +274,10 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
|
||||
assert!(s.take_sounds().is_empty(), "loot close is silent (C-side)");
|
||||
|
||||
// Re-open with an EMPTY roll: OnShow's numItems==0 fork queues exactly LOOTWINDOWOPENEMPTY.
|
||||
s.set_loot(Some(LootState { rows: vec![] }));
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
@@ -282,6 +287,58 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The fishing fork (LootFrame.lua LootFrame_OnShow l.137-140; decision 1086): a fishing loot open
|
||||
/// plays "FISHING REEL IN" (SoundEntries "Fishing Reel in", kit 3407 — the name lookup is
|
||||
/// case-insensitive on both sides) and swaps the portrait ring's skull for the FishingLoot-Icon;
|
||||
/// the next ordinary open resets the skull (the ref re-stamps TargetDead at every show, l.133).
|
||||
#[test]
|
||||
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "LootFrame.xml");
|
||||
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
|
||||
quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
|
||||
})
|
||||
};
|
||||
|
||||
let mut fished = coin_and_two_items();
|
||||
fished.fishing = true;
|
||||
s.set_loot(Some(fished));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![SoundRequest::KitName("FISHING REEL IN".into())],
|
||||
"a fishing loot open plays the reel-in kit"
|
||||
);
|
||||
let quads = s.extract();
|
||||
assert!(
|
||||
has_icon(&quads, "FishingLoot-Icon"),
|
||||
"the portrait ring shows the fishing icon"
|
||||
);
|
||||
assert!(
|
||||
!has_icon(&quads, "TargetDead"),
|
||||
"…instead of the dead-target skull"
|
||||
);
|
||||
|
||||
// Close, then an ordinary corpse loot: silent again, and the skull is back.
|
||||
s.fire_event("LOOT_CLOSED", vec![]);
|
||||
let _ = s.take_sounds();
|
||||
s.set_loot(Some(coin_and_two_items()));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
s.take_sounds().is_empty(),
|
||||
"a non-fishing loot open is silent (its open kit is C-side)"
|
||||
);
|
||||
let quads = s.extract();
|
||||
assert!(has_icon(&quads, "TargetDead"), "the skull is restored");
|
||||
assert!(!has_icon(&quads, "FishingLoot-Icon"));
|
||||
}
|
||||
|
||||
/// Paging (LootFrame.lua l.70-73,105-118): 5 items ⇒ 3 rows + a Down pager on page 1, 2 rows + an Up
|
||||
/// pager on page 2 — the real shipped XML driving the render, not just the arithmetic.
|
||||
#[test]
|
||||
@@ -303,7 +360,10 @@ fn shipped_loot_frame_pages_five_items() {
|
||||
link: None,
|
||||
})
|
||||
.collect();
|
||||
s.set_loot(Some(LootState { rows }));
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows,
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
@@ -488,6 +548,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
|
||||
|
||||
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![
|
||||
LootRow {
|
||||
item_id: 0,
|
||||
|
||||
@@ -721,6 +721,9 @@ mod questlog_tests;
|
||||
#[cfg(test)]
|
||||
mod character_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod skills_frame_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pet_paperdoll_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! The shipped **Skills tab** (`assets/ui/SkillFrame.xml`) driven end-to-end, engine-only (no
|
||||
//! Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
|
||||
//! out of `character_tests.rs` (which owns the paperdoll + the tab round-trip) so the skills-pane
|
||||
//! paint law has a home of its own.
|
||||
//!
|
||||
//! What it pins is the **proficiency row**: `SkillFrame.lua`'s own `skillMaxRank == 1` gate draws
|
||||
//! a full GRAY bar with NO rank text, and two different kinds of line land in it — the armor
|
||||
//! proficiencies, which the server really does report as `1/1`, and the **single-rank** lines
|
||||
//! (class skills, Dual Wield, racials, the per-mount riding lines), whose `skillMaxRank` the
|
||||
//! engine overrides to `1` off `SkillRaceClassInfo.flags & 0x400` however high the server's own
|
||||
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; wow-re `0x4d3610`'s `4d38b1` branch). A
|
||||
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
|
||||
//! numberless, exactly as it does in the real client.
|
||||
|
||||
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript};
|
||||
|
||||
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the
|
||||
/// character/spellbook tests' loader, duplicated so this file is self-contained).
|
||||
fn load_xml(s: &UiScript, file: &str) {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
fn skill(
|
||||
skill_id: u32,
|
||||
name: &str,
|
||||
value: u32,
|
||||
max: u32,
|
||||
mono: bool,
|
||||
(category_id, category_name, category_order): (u32, &str, u32),
|
||||
) -> SkillEntry {
|
||||
SkillEntry {
|
||||
skill_id,
|
||||
name: name.into(),
|
||||
value,
|
||||
max,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id,
|
||||
category_name: category_name.into(),
|
||||
category_order,
|
||||
description: String::new(),
|
||||
abandonable: false,
|
||||
mono,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Skills page, shown, with one line of each shape — a hunter's real vmangos numbers:
|
||||
/// `Beast Mastery 300/300` (single-rank, `SkillRaceClassInfo` 0x410), `Defense 12/60` (a normal
|
||||
/// weapon line), `Cloth 1/1` (an armor proficiency the server itself caps).
|
||||
fn shown_skills_page() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"GameTooltip.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"CharacterFrame.xml",
|
||||
"SkillFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s.set_skills(SkillsState {
|
||||
entries: vec![
|
||||
skill(50, "Beast Mastery", 300, 300, true, (7, "Class Skills", 2)),
|
||||
skill(95, "Defense", 12, 60, false, (6, "Weapon Skills", 5)),
|
||||
skill(415, "Cloth", 1, 1, false, (8, "Armor Proficiencies", 6)),
|
||||
],
|
||||
});
|
||||
s.run(r#"ToggleCharacter("BenillaSkillFrame")"#).unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
/// Row slot `i`'s rank text and bar color — the two things the proficiency gate decides.
|
||||
fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
|
||||
let text = s
|
||||
.eval::<String>(&format!(
|
||||
"return BenillaSkillRankFrame{i}SkillRank:GetText() or \"\""
|
||||
))
|
||||
.unwrap();
|
||||
let c = s
|
||||
.eval::<(f32, f32, f32, f32)>(&format!(
|
||||
"return BenillaSkillRankFrame{i}:GetStatusBarColor()"
|
||||
))
|
||||
.unwrap();
|
||||
(text, [c.0, c.1, c.2, c.3])
|
||||
}
|
||||
|
||||
/// Row slot `i`'s **trough** colour — the `$parentBackground` texture behind the fill, which the
|
||||
/// ref recolours per branch alongside the fill (`SkillFrame.lua:158` normal / `:167` proficiency).
|
||||
fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
|
||||
let c = s
|
||||
.eval::<(f32, f32, f32, f32)>(&format!(
|
||||
"return BenillaSkillRankFrame{i}Background:GetVertexColor()"
|
||||
))
|
||||
.unwrap();
|
||||
[c.0, c.1, c.2, c.3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_rank_line_paints_gray_with_no_rank_text() {
|
||||
let mut s = shown_skills_page();
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaSkillFrame:IsVisible()")
|
||||
.unwrap(),
|
||||
"the Skills page is up"
|
||||
);
|
||||
|
||||
// Visible rows: 1 header "Class Skills", 2 Beast Mastery, 3 header "Weapon Skills",
|
||||
// 4 Defense, 5 header "Armor Proficiencies", 6 Cloth — one list slot each.
|
||||
let (text, color) = row(&mut s, 2);
|
||||
assert_eq!(
|
||||
text, "",
|
||||
"Beast Mastery is single-rank: no rank text, though the server said 300/300"
|
||||
);
|
||||
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "and a gray bar");
|
||||
assert_eq!(
|
||||
row_bg(&mut s, 2),
|
||||
[1.0, 1.0, 1.0, 0.5],
|
||||
"over the proficiency branch's WHITE trough (ref SkillFrame.lua:167)"
|
||||
);
|
||||
|
||||
// The armor proficiency reaches the same branch by the server's own 1/1.
|
||||
let (text, color) = row(&mut s, 6);
|
||||
assert_eq!(text, "", "Cloth is 1/1: no rank text");
|
||||
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "gray too");
|
||||
assert_eq!(row_bg(&mut s, 6), [1.0, 1.0, 1.0, 0.5], "white trough too");
|
||||
|
||||
// The control: a normal weapon line still reads its numbers, in blue.
|
||||
let (text, color) = row(&mut s, 4);
|
||||
assert_eq!(text, "12/60", "Defense keeps its rank text");
|
||||
assert_eq!(color, [0.0, 0.0, 1.0, 0.5], "and the blue fill");
|
||||
assert_eq!(
|
||||
row_bg(&mut s, 4),
|
||||
[0.0, 0.0, 0.75, 0.5],
|
||||
"over the normal branch's DARK BLUE trough (ref SkillFrame.lua:158)"
|
||||
);
|
||||
|
||||
// And what those troughs actually DRAW at. The template declares the texture `<Color 1,1,1,0.2>`
|
||||
// — a real texel — and the vertex colour MULTIPLIES it, alpha included (the composition law,
|
||||
// `benilla-ui` `script::tests::regions`): the proficiency's white reaches the screen at
|
||||
// `0.2 x 0.5 = 0.1`, not 0.5. Every solid-colour quad this page emits is one of these troughs.
|
||||
let solids: Vec<[f32; 4]> = s
|
||||
.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: None,
|
||||
color: Some(c),
|
||||
..
|
||||
} => Some(*c),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
solids.contains(&[1.0, 1.0, 1.0, 0.1]),
|
||||
"a proficiency trough draws white at 0.1; got {solids:?}"
|
||||
);
|
||||
assert!(
|
||||
solids.contains(&[0.0, 0.0, 0.75, 0.1]),
|
||||
"a normal row's trough draws dark blue at 0.1; got {solids:?}"
|
||||
);
|
||||
assert!(
|
||||
!solids.iter().any(|c| c[3] == 0.5),
|
||||
"nothing draws at the raw vertex alpha — that was the replace bug; got {solids:?}"
|
||||
);
|
||||
|
||||
// Selecting the single-rank row paints the detail pane the same way (the shared PaintBar).
|
||||
s.run("SetSelectedSkill(2) BenillaSkillFrame_Update()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaSkillDetailBarSkillRank:GetText() or \"\"")
|
||||
.unwrap(),
|
||||
"",
|
||||
"the detail pane's own bar is a proficiency too"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return BenillaSkillDetailBarBackground:GetVertexColor()")
|
||||
.unwrap(),
|
||||
(1.0, 1.0, 1.0, 0.5),
|
||||
"including its trough (the shared PaintBar; ref SkillFrame.lua:376)"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The director's reference screenshot, reproduced row for row** (decision 1091). A real
|
||||
/// level-60 tauren hunter's `PLAYER_SKILL_INFO` block — read straight out of the live vmangos
|
||||
/// `character_skills` rows the A/B was taken on — fed through the app's own display predicate
|
||||
/// ([`crate::ui_char::skills_row`]) and the engine's grouping, against the REAL shipped DBCs.
|
||||
///
|
||||
/// What the reference client shows for exactly this block is the expectation below: four headers,
|
||||
/// fourteen rows — and **no** `Dual Wield`, `Tauren Racial` or `GENERIC (DND)`, and no `Secondary
|
||||
/// Skills` header, though the server sends all three lines at 300/300 like any other. Skips
|
||||
/// without client data.
|
||||
#[test]
|
||||
fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
|
||||
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
|
||||
|
||||
// (skill line, value, max) — Twohunter's rows, verbatim. Tauren (race 6) hunter (class 3), 60.
|
||||
const BLOCK: &[(u16, u16, u16)] = &[
|
||||
(44, 300, 300), // Axes
|
||||
(46, 300, 300), // Guns
|
||||
(50, 300, 300), // Beast Mastery — single-rank
|
||||
(51, 300, 300), // Survival — single-rank
|
||||
(95, 300, 300), // Defense
|
||||
(109, 300, 300), // Language: Orcish
|
||||
(115, 300, 300), // Language: Taurahe
|
||||
(118, 300, 300), // Dual Wield — HIDDEN
|
||||
(124, 300, 300), // Tauren Racial — HIDDEN
|
||||
(162, 300, 300), // Unarmed
|
||||
(163, 300, 300), // Marksmanship — single-rank
|
||||
(173, 300, 300), // Daggers
|
||||
(183, 300, 300), // GENERIC (DND) — HIDDEN
|
||||
(226, 300, 300), // Crossbows
|
||||
(413, 1, 1), // Mail
|
||||
(414, 1, 1), // Leather
|
||||
(415, 1, 1), // Cloth
|
||||
];
|
||||
let entries: Vec<SkillEntry> = BLOCK
|
||||
.iter()
|
||||
.filter_map(|&(skill_id, value, max)| {
|
||||
let slot = benilla_protocol::messages::PlayerSkillSlot {
|
||||
skill_id,
|
||||
step: 0,
|
||||
value,
|
||||
max,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
};
|
||||
crate::ui_char::skills_row(&slot, &catalog, 6, 3, 60)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_skills(SkillsState { entries });
|
||||
|
||||
let n = s.eval::<i64>("return GetNumSkillLines()").unwrap();
|
||||
let rows: Vec<(String, bool)> = (1..=n)
|
||||
.map(|i| {
|
||||
s.eval::<(String, Option<i64>)>(&format!(
|
||||
"local n,h = GetSkillLineInfo({i}) return n,h"
|
||||
))
|
||||
.map(|(name, header)| (name, header.is_some()))
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let expected: Vec<(&str, bool)> = vec![
|
||||
("Class Skills", true),
|
||||
("Beast Mastery", false),
|
||||
("Marksmanship", false),
|
||||
("Survival", false),
|
||||
("Weapon Skills", true),
|
||||
("Axes", false),
|
||||
("Crossbows", false),
|
||||
("Daggers", false),
|
||||
("Defense", false),
|
||||
("Guns", false),
|
||||
("Unarmed", false),
|
||||
("Armor Proficiencies", true),
|
||||
("Cloth", false),
|
||||
("Leather", false),
|
||||
("Mail", false),
|
||||
("Languages", true),
|
||||
("Language: Orcish", false),
|
||||
("Language: Taurahe", false),
|
||||
];
|
||||
let got: Vec<(&str, bool)> = rows.iter().map(|(n, h)| (n.as_str(), *h)).collect();
|
||||
assert_eq!(got, expected, "the pane, row for row");
|
||||
|
||||
// The three the server sends and the client never lists.
|
||||
for hidden in ["Dual Wield", "Tauren Racial", "GENERIC (DND)"] {
|
||||
assert!(
|
||||
!got.iter().any(|(n, _)| *n == hidden),
|
||||
"{hidden} must not appear"
|
||||
);
|
||||
}
|
||||
// And the class lines still read as proficiencies despite their 300/300 descriptor.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (select(7, GetSkillLineInfo(2)))")
|
||||
.unwrap(),
|
||||
1,
|
||||
"Beast Mastery's skillMaxRank"
|
||||
);
|
||||
}
|
||||
@@ -1389,3 +1389,59 @@ fn the_player_frame_flashes_zzz_while_resting() {
|
||||
assert!(clear, "neither state → no status dressing at all");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The zzz badge paints OVER the level number (decision 1093): the ref keeps the number in the
|
||||
/// texture frame's BACKGROUND layer and the state icons up in OVERLAY, so the opaque badge covers
|
||||
/// it while resting. The layer split is the only mechanism that CAN hide it — a fontstring never
|
||||
/// ducks under a texture of its own layer (0884's bucket-wide quads-then-text law) — which is
|
||||
/// exactly how 1082's transcription slipped: it put the number in OVERLAY beside the icons, and
|
||||
/// the number rode on the badge (director report, 2026-08-07).
|
||||
#[test]
|
||||
fn the_rest_badge_covers_the_level_number() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_unit_frames(&s);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
name: Some("Prober".into()),
|
||||
health: 100,
|
||||
max_health: 100,
|
||||
level: 12,
|
||||
power_type: 0,
|
||||
power: 80,
|
||||
max_power: 80,
|
||||
dead: false,
|
||||
reaction: 0,
|
||||
..UnitState::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.set_rest_state(1, 500, true);
|
||||
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
|
||||
// Every UI-StateIcon quad (the badge AND its ADD glow — the glow rides a later frame and
|
||||
// sits higher still): even the LOWEST must clear the number.
|
||||
let badge = quads
|
||||
.iter()
|
||||
.filter(|q| {
|
||||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||||
if p.ends_with("UI-StateIcon"))
|
||||
})
|
||||
.map(|q| q.z)
|
||||
.min()
|
||||
.expect("the zzz badge");
|
||||
let level = quads
|
||||
.iter()
|
||||
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "12"))
|
||||
.expect("the level text");
|
||||
assert!(
|
||||
badge > level.z,
|
||||
"the badge must cover the level number (badge z={badge:#x}, level z={:#x})",
|
||||
level.z
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user