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:
Sam
2026-08-07 16:03:16 +07:00
co-authored by Claude Fable 5
parent f35c3cb0f6
commit 19461d1e0f
81 changed files with 4052 additions and 660 deletions
@@ -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 -2
View File
@@ -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,
+3
View File
@@ -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());
}