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:
Sam
2026-08-25 18:43:04 +07:00
co-authored by Claude Fable 5
parent 3d10e7415b
commit 4eeed067a1
51 changed files with 2871 additions and 508 deletions
@@ -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"
);
}
+2
View File
@@ -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
);
}