landmarks and the Plaguelands towers on the map, no ultrawide crop
- the world map's landmarks: city icons and the Eastern Plaguelands towers, from AreaPOI.dbc through the reference's own three gates (level flags, exploration, world state) — the tower rows are one per (tower, owner, state), so the server flipping a world state swaps which icon exists; and the always-up PvP readout ("Towers Controlled: n") over a new WorldStateUI.dbc loader, the init clearing the table on a zone change as the client does
- the character-select scene holds its authored 4:3 view box: the diagonal-FOV relation was fed the display's aspect, so every extra pixel of width came out of height (1.55× zoom at 21:9, head and feet gone) — now width grows and height never shrinks; the reference does the same and is wrong on the same panel
- a trail particle is stored in world space and drawn raw: Sprint's trail was welded to the chest bone (turn and it swings, run and it wobbles) because the trail was faked by subtracting translation inside a bone-local store; the correction machinery deleted itself, gravity is plain world down, and the emitters' live attach frame is retired
- the spellbook can shrink: `SMSG_REMOVED_SPELL` had no handler, so a talent wipe took the points back and left every rank drawn; and the chat dock's hover driver rides a frame the dock cannot hide, so selecting the Combat Log no longer freezes the tab alphas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -784,3 +784,137 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Selecting the Combat Log must not stop the dock.** The falsification for decision 1588.
|
||||
///
|
||||
/// `FCF_OnUpdate` is the dock's whole driver — the hover reveal, both tabs' alphas, the label
|
||||
/// settle, the whisper blink — and it used to ride `ChatFrame1`'s own `OnUpdate`.
|
||||
/// `BenillaFCF_SelectDock(2)` hides ChatFrame1, and a hidden frame gets no `OnUpdate`
|
||||
/// ([`UiScript::tick`] runs only effectively-visible frames, which is the reference's rule), so the
|
||||
/// driver died at the exact moment the feature it drives was first used: the tabs froze mid-write,
|
||||
/// still showing General as the selected one. That is what the director saw and reported, and every
|
||||
/// test we had passed straight through it — including this file's two dock tests, because both only
|
||||
/// ever exercise window 1.
|
||||
///
|
||||
/// The trap this walks around: calling `FCF_OnUpdate()` by hand — which the first live probe did,
|
||||
/// and which every earlier test does — bypasses the visibility gate and reports healthy alphas on a
|
||||
/// dead driver. So this drives the clock and nothing else.
|
||||
#[test]
|
||||
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
|
||||
let mut s = chat_frame();
|
||||
// Hover the dock and let the reveal settle: General at full, Combat Log at half.
|
||||
let (x, y): (f32, f32) = s
|
||||
.eval(
|
||||
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
|
||||
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
|
||||
)
|
||||
.unwrap();
|
||||
s.mouse_move(x, y);
|
||||
for _ in 0..25 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(
|
||||
close(
|
||||
s.eval::<f32>("return ChatFrame1Tab:GetAlpha()").unwrap(),
|
||||
1.0
|
||||
) && close(
|
||||
s.eval::<f32>("return ChatFrame2Tab:GetAlpha()").unwrap(),
|
||||
0.5
|
||||
),
|
||||
"the hovered dock starts with General selected — this test must not pass vacuously"
|
||||
);
|
||||
|
||||
// Click the Combat Log tab, then advance the clock WITHOUT touching FCF_OnUpdate.
|
||||
s.run("BenillaFCF_TabClick(2)").unwrap();
|
||||
for _ in 0..10 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
assert!(
|
||||
s.eval::<bool>("return ChatFrame2:IsShown() and not ChatFrame1:IsShown()")
|
||||
.unwrap(),
|
||||
"the click swapped the windows"
|
||||
);
|
||||
let (a1, a2): (f32, f32) = s
|
||||
.eval("return ChatFrame1Tab:GetAlpha(), ChatFrame2Tab:GetAlpha()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
close(a1, 0.5) && close(a2, 1.0),
|
||||
"the selected tab is the one at full alpha — got General {a1}, Combat Log {a2}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same freeze, and the one a screenshot cannot show: with the driver dead,
|
||||
/// `reveal` never decayed either, so the chat box and both tabs stayed lit after the cursor left.
|
||||
#[test]
|
||||
fn the_dock_still_fades_out_with_the_combat_log_selected() {
|
||||
let mut s = chat_frame();
|
||||
let (x, y): (f32, f32) = s
|
||||
.eval(
|
||||
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
|
||||
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
|
||||
)
|
||||
.unwrap();
|
||||
s.mouse_move(x, y);
|
||||
for _ in 0..25 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
s.run("BenillaFCF_TabClick(2)").unwrap();
|
||||
for _ in 0..10 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(
|
||||
close(
|
||||
s.eval::<f32>("return ChatFrame2Tab:GetAlpha()").unwrap(),
|
||||
1.0
|
||||
),
|
||||
"revealed before the cursor leaves"
|
||||
);
|
||||
|
||||
s.mouse_move(1500.0, 850.0); // away from the dock
|
||||
for _ in 0..20 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
let a2: f32 = s.eval("return ChatFrame2Tab:GetAlpha()").unwrap();
|
||||
assert!(
|
||||
a2 < 1e-6,
|
||||
"the dock conceals itself again on leave — got Combat Log tab alpha {a2}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Combat Log window has the scroll column too — the third part of window 2 that was simply
|
||||
/// never authored (after the tab, 1575, and the border art, 1579). It is a behavioural check, not
|
||||
/// a name check: scroll up and the bottom button's flash must start blinking on window 2's own
|
||||
/// button, which only happens if window 2 both HAS the button and runs `ChatFrame_OnUpdate`.
|
||||
#[test]
|
||||
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
|
||||
let mut s = chat_frame();
|
||||
s.run("BenillaFCF_TabClick(2)").unwrap();
|
||||
for i in 0..40 {
|
||||
s.add_chat_message("ChatFrame2", &format!("line {i}"), 1.0, 1.0, 1.0);
|
||||
}
|
||||
s.run("ChatFrame2:ScrollUp()").unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return ChatFrame2:AtBottom()").unwrap(),
|
||||
"scrolled off the bottom — this test must not pass vacuously"
|
||||
);
|
||||
|
||||
// CHAT_BUTTON_FLASH_TIME is 0.5 s; 40 frames of 16 ms crosses it.
|
||||
for _ in 0..40 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert!(
|
||||
s.eval::<bool>("return ChatFrame2BottomButtonFlash:IsVisible()")
|
||||
.unwrap(),
|
||||
"window 2's bottom-button flash blinks while it is scrolled up"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -850,6 +850,8 @@ mod quest_timer_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod durability_tests;
|
||||
#[cfg(test)]
|
||||
mod world_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod screenshot_tests;
|
||||
|
||||
@@ -223,6 +223,12 @@ mod tests {
|
||||
("ui_script/extract/mod.rs", "last_dpi"),
|
||||
// Pushed unconditionally every frame; there is nothing remembered to go stale.
|
||||
("ui_script/mod.rs", "smoothed"),
|
||||
// A fact about the CHAT ROSTER, not about the VM: whether the player has joined a
|
||||
// zone-dependent defense channel (the world-state readout's `Type == 1` gate). It is
|
||||
// recomputed whenever `ChannelState` changes and is the same answer either side of a
|
||||
// login; the push it feeds is memoized beside it in a real `VmMemo`, which is what
|
||||
// re-seats the new VM.
|
||||
("world_state_ui.rs", "defense_channel"),
|
||||
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
|
||||
// pointer. The OS keeps that state across the VM's death and rebirth, so re-seating them at
|
||||
// a login would re-assert a cursor nothing changed. (`last_set` is the key we last handed
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! The always-up world-state readout (`assets/ui/WorldStateFrame.xml`) against the shipped XML —
|
||||
//! report B190's second half, driven the way the client drives it: rows pushed, then
|
||||
//! `UPDATE_WORLD_STATES` fired.
|
||||
//!
|
||||
//! What these pin is the *frame's* half of the contract — that it reads the ten returns in the
|
||||
//! right order, swaps the dynamic icon on the row's own state, and empties itself when a scope
|
||||
//! admits nothing. Which rows exist at all is [`crate::world_state_ui`]'s, tested there against
|
||||
//! the real `WorldStateUI.dbc`.
|
||||
|
||||
use benilla_ui::script::{QuadContent, ScriptValue, UiScript, WorldStateUiView};
|
||||
|
||||
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 harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in [
|
||||
"Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
"UIParent.xml",
|
||||
"GameTooltip.xml",
|
||||
"WorldStateFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// An Eastern Plaguelands-shaped row.
|
||||
fn row(icon: &str, text: &str, tooltip: &str) -> WorldStateUiView {
|
||||
WorldStateUiView {
|
||||
ui_state: 1,
|
||||
text: text.into(),
|
||||
icon: icon.into(),
|
||||
tooltip: tooltip.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every shown text string, in draw order.
|
||||
fn texts(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Text { text: Some(t), .. } if !t.is_empty() => Some(t.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every shown texture path, in draw order.
|
||||
fn textures(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn push(s: &mut UiScript, rows: Vec<WorldStateUiView>) {
|
||||
s.set_world_state_ui(rows);
|
||||
s.tick(0.0);
|
||||
s.fire_event("UPDATE_WORLD_STATES", vec![]);
|
||||
}
|
||||
|
||||
/// The Eastern Plaguelands readout: two labelled tower counters, each with its faction icon.
|
||||
#[test]
|
||||
fn the_tower_counters_draw_with_their_icons() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
assert!(
|
||||
!texts(&mut s).iter().any(|t| t.contains("Towers")),
|
||||
"nothing before a push"
|
||||
);
|
||||
|
||||
push(
|
||||
&mut s,
|
||||
vec![
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\AllianceTower",
|
||||
"Towers Controlled: 3",
|
||||
"Alliance Towers Controlled",
|
||||
),
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\HordeTower",
|
||||
"Towers Controlled: 1",
|
||||
"Horde Towers Controlled",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let shown = texts(&mut s);
|
||||
assert!(
|
||||
shown.contains(&"Towers Controlled: 3".to_string())
|
||||
&& shown.contains(&"Towers Controlled: 1".to_string()),
|
||||
"both counters on screen: {shown:?}"
|
||||
);
|
||||
let art = textures(&mut s);
|
||||
assert!(
|
||||
art.iter().any(|p| p.contains("AllianceTower"))
|
||||
&& art.iter().any(|p| p.contains("HordeTower")),
|
||||
"both faction icons on screen: {art:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A scope that admits nothing empties the readout — and the pooled rows from the busier scope
|
||||
/// are hidden rather than left painting a stale count.
|
||||
#[test]
|
||||
fn leaving_the_zone_clears_the_readout() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
push(
|
||||
&mut s,
|
||||
vec![row(
|
||||
"Interface\\WorldStateFrame\\AllianceTower",
|
||||
"Towers Controlled: 3",
|
||||
"Alliance Towers Controlled",
|
||||
)],
|
||||
);
|
||||
assert!(texts(&mut s).contains(&"Towers Controlled: 3".to_string()));
|
||||
|
||||
push(&mut s, Vec::new());
|
||||
assert!(
|
||||
!texts(&mut s).iter().any(|t| t.contains("Towers")),
|
||||
"no stale count left painting: {:?}",
|
||||
texts(&mut s)
|
||||
);
|
||||
assert!(
|
||||
!textures(&mut s).iter().any(|p| p.contains("AllianceTower")),
|
||||
"and no orphaned icon"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dynamic icon: a row whose own state is live draws the alternate art (Warsong Gulch's enemy
|
||||
/// flag) instead of its static icon, and reverts when the state drops. `uiState` is what decides,
|
||||
/// which is the whole reason the first return is a number and not the text.
|
||||
#[test]
|
||||
fn the_dynamic_icon_follows_the_rows_own_state() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
|
||||
let flag_row = |ui_state| WorldStateUiView {
|
||||
ui_state,
|
||||
text: "2/3".into(),
|
||||
icon: "Interface\\TargetingFrame\\UI-PVP-Alliance".into(),
|
||||
dynamic_icon: "Interface\\WorldStateFrame\\HordeFlag".into(),
|
||||
tooltip: "Alliance flag captures".into(),
|
||||
dynamic_tooltip: "Horde flag has been picked up".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
push(&mut s, vec![flag_row(0)]);
|
||||
let art = textures(&mut s);
|
||||
assert!(
|
||||
art.iter().any(|p| p.contains("UI-PVP-Alliance")),
|
||||
"state down — the static icon: {art:?}"
|
||||
);
|
||||
assert!(!art.iter().any(|p| p.contains("HordeFlag")));
|
||||
|
||||
push(&mut s, vec![flag_row(1)]);
|
||||
let art = textures(&mut s);
|
||||
assert!(
|
||||
art.iter().any(|p| p.contains("HordeFlag")),
|
||||
"state up — the enemy flag replaces it: {art:?}"
|
||||
);
|
||||
assert!(!art.iter().any(|p| p.contains("UI-PVP-Alliance")));
|
||||
}
|
||||
|
||||
/// A row with no icon at all (the Eastern Plaguelands progress line) still draws its text — the
|
||||
/// icon is optional, and the row must not collapse when the DBC column is empty.
|
||||
#[test]
|
||||
fn a_row_without_an_icon_still_shows_its_text() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
push(
|
||||
&mut s,
|
||||
vec![WorldStateUiView {
|
||||
ui_state: 1,
|
||||
text: "Progress: 60".into(),
|
||||
extended_ui: "CAPTUREPOINT".into(),
|
||||
extended_ui_state: [60, 40, 0],
|
||||
..Default::default()
|
||||
}],
|
||||
);
|
||||
assert!(texts(&mut s).contains(&"Progress: 60".to_string()));
|
||||
}
|
||||
|
||||
/// The bindings' own edges, read from Lua the way an addon would. Two are easy to get wrong and
|
||||
/// both are the reference's (`0x4c5a40`/`0x4c5a70`): an out-of-range index answers exactly one
|
||||
/// value — the number `0`, not nil and not ten nils — and a non-number argument raises.
|
||||
#[test]
|
||||
fn the_bindings_answer_the_reference_shape() {
|
||||
let mut s = harness();
|
||||
s.set_world_state_ui(vec![WorldStateUiView {
|
||||
ui_state: 7,
|
||||
text: "Towers Controlled: 3".into(),
|
||||
icon: "Interface\\WorldStateFrame\\AllianceTower".into(),
|
||||
tooltip: "Alliance Towers Controlled".into(),
|
||||
extended_ui_state: [1, 2, 3],
|
||||
..Default::default()
|
||||
}]);
|
||||
|
||||
assert_eq!(s.eval::<i64>("return GetNumWorldStateUI()").unwrap(), 1);
|
||||
// Ten values, in order, with no nils among the strings.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local a,b,c,d,e,f,g,h,i,j = GetWorldStateUIInfo(1)\n\
|
||||
return a..'|'..b..'|'..c..'|'..d..'|'..e..'|'..f..'|'..g..'|'..h..'|'..i..'|'..j"
|
||||
)
|
||||
.unwrap(),
|
||||
"7|Towers Controlled: 3|Interface\\WorldStateFrame\\AllianceTower||\
|
||||
Alliance Towers Controlled|||1|2|3",
|
||||
"empty columns are empty strings, never nil"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetWorldStateUIInfo(1))")
|
||||
.unwrap(),
|
||||
10
|
||||
);
|
||||
|
||||
// Out of range: ONE value, the number 0.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetWorldStateUIInfo(2))")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetWorldStateUIInfo(2)").unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetWorldStateUIInfo(0)").unwrap(),
|
||||
0,
|
||||
"the index is 1-based"
|
||||
);
|
||||
|
||||
// A non-number argument raises rather than answering quietly.
|
||||
assert!(
|
||||
s.eval::<i64>("return GetWorldStateUIInfo('nope')").is_err(),
|
||||
"a non-numeric argument is an error, not a nil"
|
||||
);
|
||||
// ...but a numeric string coerces, as `lua_isnumber` does.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (GetWorldStateUIInfo('1'))").unwrap(),
|
||||
7
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user