merge: absorb samwhosung/main on top of the upstream merge
Same fork policy: upstream structure wins, local-unique re-instated. Cargo.toml unions both measured dev-profile philosophies (codegen-units=1 + lto=off from samwhosung, debug=line-tables-only + split-debuginfo from the fork's disk measurements). glue_booth.rs takes samwhosung's typed GlueScene (token/of_race/from_model_path) and re-adds the fork's TurtleWoW races (Goblin=9, BloodElf=10) as variants; their glue fog stays on Human's row, exactly as the fork's scene_fog fallback did. ui script takes samwhosung's mouseover/hyperlink rework (world_mouseover, minimap_blip, hyperlink.rs) over the fork's inline hover_link/span_at/pointer path — the rework covers the same OnHyperlinkEnter/Leave behaviour. game_plugins.rs keeps the fork's re-measured ceilings (466 / 5,509); mail.rs keeps the explicit 13-tuple with a float daysLeft; MAP.md takes the superset.
This commit is contained in:
@@ -320,6 +320,109 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `SetAction` answers 1 when the spell tooltip it built holds a line that changes while shown
|
||||
/// (the chance line, or the remaining cooldown of a spell whose recovery is a minute or more:
|
||||
/// `0x52f68c`, `0x52f581`), else nil, and the stock `ActionButton_SetTooltip` arms `updateTooltip`
|
||||
/// from it (`ActionButton.lua:377-381`); `ActionButton_OnUpdate` then rebuilds the hovered
|
||||
/// tooltip. Run through the stock bar.
|
||||
#[test]
|
||||
fn a_spell_tooltip_with_a_live_line_arms_the_stock_refresh() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
use benilla_ui::script::{ActionState, SpellTooltipView};
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
s.set_action(
|
||||
1,
|
||||
Some(ActionSlot {
|
||||
texture: Some("Interface\\Icons\\Ability_Warrior_ShieldWall".into()),
|
||||
kind: 0x00,
|
||||
action: 871,
|
||||
count: 0,
|
||||
consumable: false,
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.tick(10.0); // a nonzero GetTime epoch
|
||||
let view = |cast: &str, recovery_ms: u32, chance: Option<&str>| SpellTooltipView {
|
||||
name: "Shield Wall".into(),
|
||||
cast_time: Some(cast.into()),
|
||||
recovery_ms,
|
||||
chance: chance.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
// The value the stock button saved from the setter's answer.
|
||||
let armed = |s: &mut UiScript| -> Option<f64> {
|
||||
s.eval::<Option<f64>>("return ActionButton1.updateTooltip")
|
||||
.unwrap()
|
||||
};
|
||||
let line2 = |s: &mut UiScript| -> String {
|
||||
s.eval::<String>("return GameTooltipTextLeft2:GetText() or ''")
|
||||
.unwrap()
|
||||
};
|
||||
let running = |duration_ms: u32| ActionState {
|
||||
usable: true,
|
||||
// Started at 9.4 s on the GetTime clock, so it is running at 10 s.
|
||||
cooldown: Some((9_400, duration_ms, true)),
|
||||
..Default::default()
|
||||
};
|
||||
let leave_and_enter = |s: &mut UiScript| {
|
||||
super::test_ui::unhover(s);
|
||||
s.tick(0.0);
|
||||
super::test_ui::hover(s, "ActionButton1");
|
||||
};
|
||||
|
||||
// No cooldown, no chance line: the setter answers nil, and nothing is armed.
|
||||
s.set_spell_tooltip(871, view("Instant", 1_800_000, None));
|
||||
super::test_ui::hover(&mut s, "ActionButton1");
|
||||
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
|
||||
assert_eq!(armed(&mut s), None);
|
||||
|
||||
// A 30 minute recovery running: 1, so the button arms the stock refresh.
|
||||
s.set_action_state(1, Some(running(1_800_000)));
|
||||
leave_and_enter(&mut s);
|
||||
assert_eq!(
|
||||
armed(&mut s),
|
||||
s.eval::<Option<f64>>("return TOOLTIP_UPDATE_TIME").unwrap()
|
||||
);
|
||||
assert!(armed(&mut s).is_some_and(|t| t > 0.0));
|
||||
assert_eq!(line2(&mut s), "Instant");
|
||||
// The refresh rebuilds the hovered tooltip from the current view.
|
||||
s.set_spell_tooltip(871, view("Next melee", 1_800_000, None));
|
||||
s.tick(0.5);
|
||||
assert_eq!(
|
||||
line2(&mut s),
|
||||
"Next melee",
|
||||
"ActionButton_OnUpdate rebuilt it"
|
||||
);
|
||||
|
||||
// The same cooldown on a spell whose recovery is under a minute: nil.
|
||||
s.set_spell_tooltip(871, view("Instant", 30_000, None));
|
||||
s.set_action_state(1, Some(running(30_000)));
|
||||
leave_and_enter(&mut s);
|
||||
assert_eq!(armed(&mut s), None);
|
||||
|
||||
// A chance line alone does not: `SetAction` reads the builder's value only with a cooldown
|
||||
// running (`0x532597`; the other leg pushes nil, `0x532690`), so a passive answers nil.
|
||||
s.set_action_state(
|
||||
1,
|
||||
Some(ActionState {
|
||||
usable: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.set_spell_tooltip(871, view("Instant", 0, Some("5.00% chance to parry")));
|
||||
leave_and_enter(&mut s);
|
||||
assert_eq!(armed(&mut s), None);
|
||||
// With a cooldown running it does, whatever the spell's recovery.
|
||||
s.set_action_state(1, Some(running(30_000)));
|
||||
leave_and_enter(&mut s);
|
||||
assert!(armed(&mut s).is_some_and(|t| t > 0.0));
|
||||
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `CooldownFrame_SetTimer` hides the pane unless `start > 0 and duration > 0 and enable > 0`
|
||||
/// (Cooldown.lua:3): a running cooldown whose start predates a restarted `GetTime` never draws.
|
||||
#[test]
|
||||
@@ -826,7 +929,7 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() {
|
||||
s.resolve();
|
||||
|
||||
s.mouse_button(26.0, 22.0, "LeftButton", true);
|
||||
s.mouse_move(40.0, 22.0); // past the 4px drag-start threshold
|
||||
s.mouse_move(40.0, 22.0); // past the drag-start distance
|
||||
let consumed = s.mouse_button(68.0, 22.0, "LeftButton", false);
|
||||
assert!(consumed, "OnReceiveDrag consumed the release");
|
||||
|
||||
@@ -1061,6 +1164,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: None,
|
||||
locked: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -393,6 +393,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
|
||||
count: 200,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 2947,
|
||||
link: Some("|cffffffff|Hitem:2947|h[Small Throwing Knife]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -470,6 +471,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 25,
|
||||
link: Some("|cffffffff|Hitem:25|h[Worn Shortsword]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -577,6 +579,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
|
||||
count: 4,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 2318,
|
||||
link: Some("|cffffffff|Hitem:2318|h[Light Leather]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -599,6 +602,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 9999,
|
||||
link: Some("|cffffffff|Hitem:9999|h[Shadowforge Key]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -786,6 +790,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -872,6 +877,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
|
||||
count: 1,
|
||||
quality: Some(2),
|
||||
inventory_type: 0,
|
||||
item_id: 200,
|
||||
link: Some("|cffffffff|Hitem:200|h[Shiny Gem]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -982,7 +988,7 @@ fn equipped_bag_window_snug_fits_its_row_count() {
|
||||
}
|
||||
|
||||
/// Open a backpack holding a five-stack in slot 1; returns that slot button's centre.
|
||||
fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
for file in crate::ui_script::test_ui::production_order(&[
|
||||
BAG_UI,
|
||||
&[
|
||||
@@ -1008,6 +1014,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -129,7 +129,7 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
|
||||
|
||||
let mut registry: Vec<AddOnInfo> = INSTALLED
|
||||
.iter()
|
||||
.map(|n| super::addons::info_from_toc(n, &read_toc(root, n)))
|
||||
.map(|n| super::addons::info_from_toc(n, &read_toc(root, n), false))
|
||||
.collect();
|
||||
s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
|
||||
s.set_realm_name("Harness");
|
||||
@@ -576,7 +576,7 @@ fn dragging_a_bagnon_slot_picks_the_item_up() {
|
||||
let (dx, dy) = centre_of(&mut s, &empty);
|
||||
s.mouse_move(sx, sy);
|
||||
s.mouse_button(sx, sy, "LeftButton", true);
|
||||
s.mouse_move(sx + 12.0, sy + 12.0); // past DRAG_START_THRESHOLD ⇒ OnDragStart
|
||||
s.mouse_move(sx + 12.0, sy + 12.0); // past the drag-start distance ⇒ OnDragStart
|
||||
s.mouse_move(dx, dy);
|
||||
s.mouse_button(dx, dy, "LeftButton", false);
|
||||
|
||||
|
||||
@@ -90,9 +90,9 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
|
||||
"reported"
|
||||
);
|
||||
// An engine-caught error still lands in `errors()`; the dispatch adds the dialog on top.
|
||||
s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('ERROR_PROBE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)")
|
||||
s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('PLAYER_ALIVE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)")
|
||||
.unwrap();
|
||||
s.fire_event("ERROR_PROBE", vec![]);
|
||||
s.fire_event("PLAYER_ALIVE", vec![]);
|
||||
assert!(
|
||||
s.errors().iter().any(|e| e.contains("fault")),
|
||||
"engine-caught errors stay on the host channel: {:?}",
|
||||
|
||||
@@ -86,6 +86,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
count: 1,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 2000,
|
||||
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -509,8 +510,20 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
// The predicate the arm branches on, pinned beside the effect.
|
||||
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
|
||||
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
|
||||
// `CursorCanGoInSlot` (`0x4c8ff0`) pushes the number 1 (`0x4c9063`) or nil (`0x4c909e`) and
|
||||
// returns one value (`arg.n` counts the returns).
|
||||
let ask =
|
||||
"local function c(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end";
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("{ask} return c(CursorCanGoInSlot(1))"))
|
||||
.unwrap(),
|
||||
"1:number:1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("{ask} return c(CursorCanGoInSlot(2))"))
|
||||
.unwrap(),
|
||||
"1:nil:nil"
|
||||
);
|
||||
|
||||
assert!(
|
||||
ring_lit(&mut s, "CharacterHeadSlot"),
|
||||
|
||||
@@ -79,7 +79,11 @@ fn left_click(s: &mut UiScript, frame: &str) {
|
||||
/// The tabs ship hidden until a stationary hover reveals them (`FCF_OnUpdate`), and a window
|
||||
/// moves by its tab (`OnDragStart` calls `StartMoving`), so every drag starts here.
|
||||
fn reveal(s: &mut UiScript) {
|
||||
let (cx, cy) = centre(s, "ChatFrame1");
|
||||
reveal_window(s, "ChatFrame1");
|
||||
}
|
||||
|
||||
fn reveal_window(s: &mut UiScript, window: &str) {
|
||||
let (cx, cy) = centre(s, window);
|
||||
s.mouse_move(cx, cy);
|
||||
for _ in 0..45 {
|
||||
s.tick(0.016);
|
||||
@@ -87,11 +91,14 @@ fn reveal(s: &mut UiScript) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A move past the drag-start distance (0.01 of the 1600×900 diagonal, 15.7 root units).
|
||||
const DRAG_STARTS_AT: f32 = 20.0;
|
||||
|
||||
fn drag_tab(s: &mut UiScript, dx: f32, dy: f32) {
|
||||
reveal(s);
|
||||
let (tx, ty) = centre(s, "ChatFrame1Tab");
|
||||
s.mouse_button(tx, ty, "LeftButton", true);
|
||||
s.mouse_move(tx + 5.0, ty); // past the 4px threshold: OnDragStart -> StartMoving
|
||||
s.mouse_move(tx + DRAG_STARTS_AT, ty); // past the drag-start distance: OnDragStart -> StartMoving
|
||||
s.mouse_move(tx + dx, ty + dy);
|
||||
s.mouse_button(tx + dx, ty + dy, "LeftButton", false);
|
||||
s.resolve();
|
||||
@@ -280,18 +287,18 @@ fn the_tab_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
|
||||
reveal(&mut s);
|
||||
let (tx, ty) = centre(&mut s, "ChatFrame1Tab");
|
||||
s.mouse_button(tx, ty, "LeftButton", true);
|
||||
s.mouse_move(tx + 5.0, ty);
|
||||
s.mouse_move(tx + DRAG_STARTS_AT, ty);
|
||||
s.mouse_move(tx + 80.0, ty);
|
||||
assert_eq!(
|
||||
left(&mut s),
|
||||
start + 75.0,
|
||||
start + 80.0 - DRAG_STARTS_AT,
|
||||
"the window followed the cursor from where the drag started"
|
||||
);
|
||||
s.mouse_button(tx + 80.0, ty, "LeftButton", false);
|
||||
s.mouse_move(tx + 400.0, ty);
|
||||
assert_eq!(
|
||||
left(&mut s),
|
||||
start + 75.0,
|
||||
start + 80.0 - DRAG_STARTS_AT,
|
||||
"OnDragStop ended it — a move that outlived the button would keep going"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
@@ -314,8 +321,8 @@ fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
|
||||
let moved = left(&mut s);
|
||||
assert_eq!(
|
||||
moved,
|
||||
seated + 115.0,
|
||||
"120 less the 5px spent on the threshold"
|
||||
seated + 120.0 - DRAG_STARTS_AT,
|
||||
"120 less the move spent on the drag-start distance"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
|
||||
@@ -354,7 +361,9 @@ fn the_geometry_round_trips_through_the_save_file() {
|
||||
assert_eq!(saved.len(), 1, "one window was placed: {saved:?}");
|
||||
assert_eq!(saved[0].name, "ChatFrame1");
|
||||
let text = crate::ui_layout::render(&saved);
|
||||
let read_back = crate::ui_layout::parse(&text);
|
||||
let crate::ui_layout::Layout::Rect(read_back) = crate::ui_layout::parse(&text) else {
|
||||
panic!("written in the rect form:\n{text}");
|
||||
};
|
||||
assert_eq!(
|
||||
read_back, saved,
|
||||
"the file expresses what the seam produced"
|
||||
@@ -517,3 +526,158 @@ fn the_docked_combat_log_cannot_be_moved_or_resized_on_its_own() {
|
||||
assert_eq!(one, two, "the dock moved as one");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Drag `window`'s tab by `(dx, dy)`: press, past the drag threshold, move, release.
|
||||
fn drag_named_tab(s: &mut UiScript, window: &str, dx: f32, dy: f32) {
|
||||
reveal_window(s, window);
|
||||
let tab = format!("{window}Tab");
|
||||
let tab = tab.as_str();
|
||||
let (tx, ty) = centre(s, tab);
|
||||
assert_eq!(
|
||||
s.hit_test_name(tx, ty).as_deref(),
|
||||
Some(tab),
|
||||
"the tab is hit"
|
||||
);
|
||||
// As a player's mouse runs it: the hover is on the tab before the press, and a frame resolves
|
||||
// the moved layout before the release tests the cursor against the tab.
|
||||
s.mouse_move(tx, ty);
|
||||
s.mouse_button(tx, ty, "LeftButton", true);
|
||||
s.mouse_move(tx + DRAG_STARTS_AT, ty);
|
||||
s.mouse_move(tx + dx, ty + dy);
|
||||
s.resolve();
|
||||
s.mouse_button(tx + dx, ty + dy, "LeftButton", false);
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// `FCF_Tab_OnClick` reads `GetButtonState()` inside `OnClick`: NORMAL on the press (`0x779285`
|
||||
/// before the push `0x7792b1`) so an undocked tab picks its window up, PUSHED on the release
|
||||
/// (`0x7793a9` before `0x7793de`) so it drops it. A tab pulled out of the dock drags again, and a
|
||||
/// plain click picks the window up and puts it down, never leaving it on the cursor.
|
||||
#[test]
|
||||
fn an_undocked_tab_drags_again_and_a_plain_click_drops_the_window() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = chat_ui();
|
||||
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
|
||||
s.resolve();
|
||||
drag_named_tab(&mut s, "ChatFrame2", 0.0, -200.0);
|
||||
assert!(
|
||||
s.eval::<bool>("return not ChatFrame2.isDocked").unwrap(),
|
||||
"the first drag pulled the window out of the dock"
|
||||
);
|
||||
assert!(s.eval::<bool>("return MOVING_CHATFRAME == nil").unwrap());
|
||||
|
||||
let first = centre(&mut s, "ChatFrame2");
|
||||
drag_named_tab(&mut s, "ChatFrame2", 0.0, 120.0);
|
||||
let second = centre(&mut s, "ChatFrame2");
|
||||
assert_eq!(
|
||||
second.1,
|
||||
first.1 + 120.0,
|
||||
"the second drag of the undocked tab moved the window"
|
||||
);
|
||||
|
||||
reveal_window(&mut s, "ChatFrame2");
|
||||
let (tx, ty) = centre(&mut s, "ChatFrame2Tab");
|
||||
s.mouse_move(tx, ty);
|
||||
s.mouse_button(tx, ty, "LeftButton", true);
|
||||
assert!(
|
||||
s.eval::<bool>("return MOVING_CHATFRAME == ChatFrame2")
|
||||
.unwrap(),
|
||||
"the press picked the window up"
|
||||
);
|
||||
s.mouse_button(tx, ty, "LeftButton", false);
|
||||
assert!(
|
||||
s.eval::<bool>("return MOVING_CHATFRAME == nil").unwrap(),
|
||||
"the release put it down"
|
||||
);
|
||||
let before = centre(&mut s, "ChatFrame2");
|
||||
s.mouse_move(tx + 90.0, ty + 40.0);
|
||||
assert_eq!(
|
||||
centre(&mut s, "ChatFrame2"),
|
||||
before,
|
||||
"the window does not follow a cursor with no button held"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `(left, right, bottom, top)` of `frame`, after a resolve.
|
||||
fn edges(s: &mut UiScript, frame: &str) -> (f32, f32, f32, f32) {
|
||||
s.resolve();
|
||||
let (l, r, b, t): (f64, f64, f64, f64) = s
|
||||
.eval(&format!(
|
||||
"return {frame}:GetLeft(), {frame}:GetRight(), {frame}:GetBottom(), {frame}:GetTop()"
|
||||
))
|
||||
.unwrap();
|
||||
(l as f32, r as f32, b as f32, t as f32)
|
||||
}
|
||||
|
||||
/// Unlocking a docked window re-anchors it at CENTER (`FloatingChatFrame.lua:785-792`); a grip
|
||||
/// then moves only the grabbed edge, since the resize starts by pinning the window to one TOPLEFT
|
||||
/// anchor at its rect (`0x7652b0` → `0x768430`).
|
||||
#[test]
|
||||
fn an_unlocked_centred_window_resizes_from_the_grabbed_edge_only() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = chat_ui();
|
||||
reveal(&mut s);
|
||||
right_click(&mut s, "ChatFrame2Tab");
|
||||
assert!(s
|
||||
.eval::<bool>("return DropDownList1Button1:GetText() == UNLOCK_WINDOW")
|
||||
.unwrap());
|
||||
left_click(&mut s, "DropDownList1Button1");
|
||||
assert_eq!(
|
||||
s.eval::<(String, i64)>("return (ChatFrame2:GetPoint(1)), ChatFrame2:GetNumPoints()")
|
||||
.unwrap(),
|
||||
("CENTER".to_string(), 1),
|
||||
"FCF_ToggleLock undocked it and centred it"
|
||||
);
|
||||
|
||||
for (grip, dx, dy) in [("Right", 40.0, 0.0), ("Top", 0.0, 30.0)] {
|
||||
reveal_window(&mut s, "ChatFrame2");
|
||||
let before = edges(&mut s, "ChatFrame2");
|
||||
let name = format!("ChatFrame2Resize{grip}");
|
||||
let (x, y) = centre(&mut s, &name);
|
||||
assert_eq!(s.hit_test_name(x, y).as_deref(), Some(name.as_str()));
|
||||
s.mouse_button(x, y, "LeftButton", true);
|
||||
s.mouse_move(x + dx, y + dy);
|
||||
s.mouse_button(x + dx, y + dy, "LeftButton", false);
|
||||
let after = edges(&mut s, "ChatFrame2");
|
||||
assert_eq!(
|
||||
after,
|
||||
(before.0, before.1 + dx, before.2, before.3 + dy),
|
||||
"the {grip} grip moved its own edge and no other"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A tab dropped off the bottom of the screen: `FCF_ValidateChatFramePosition`'s
|
||||
/// `SetPoint("TOPLEFT", …)` (`FloatingChatFrame.lua:1304-1334`) replaces the drag's one TOPLEFT
|
||||
/// anchor, so the window comes back whole at its own size.
|
||||
#[test]
|
||||
fn a_tab_dropped_off_screen_puts_the_window_back_whole() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = chat_ui();
|
||||
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
|
||||
let (w, h) = (width(&mut s), height(&mut s));
|
||||
drag_tab(&mut s, 0.0, -600.0);
|
||||
let (n, point, rel, rp) = s
|
||||
.eval::<(i64, String, String, String)>(
|
||||
"local p, rel, rp = ChatFrame1:GetPoint(1) \
|
||||
return ChatFrame1:GetNumPoints(), p, rel:GetName(), rp",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(n, point.as_str(), rel.as_str(), rp.as_str()),
|
||||
(1, "TOPLEFT", "UIParent", "TOPLEFT"),
|
||||
"the validate's anchor is the only one"
|
||||
);
|
||||
assert_eq!((width(&mut s), height(&mut s)), (w, h), "at its own size");
|
||||
let want_top: f64 = s
|
||||
.eval("return MainMenuBar:GetHeight() + ChatFrame1Tab:GetHeight()")
|
||||
.unwrap();
|
||||
let top: f64 = s.eval("return ChatFrame1:GetTop()").unwrap();
|
||||
assert!(
|
||||
(top - want_top).abs() < 1e-3,
|
||||
"back on screen: {top} vs {want_top}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Drag Fireball off its book button (press → past the 4px threshold → the payload is up).
|
||||
// Drag Fireball off its book button (press → past the drag-start distance → the payload is up).
|
||||
let (l, r, t, b) = (
|
||||
s.eval::<f32>("return SpellButton1:GetLeft()").unwrap(),
|
||||
s.eval::<f32>("return SpellButton1:GetRight()").unwrap(),
|
||||
@@ -360,6 +360,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: None,
|
||||
locked: false,
|
||||
@@ -1259,3 +1260,85 @@ fn the_stock_afk_and_dnd_commands_reach_the_wire() {
|
||||
"a real ChatTypeInfo key that is nonetheless not sendable stays None"
|
||||
);
|
||||
}
|
||||
|
||||
/// A session's dock on the whole shipped UI: the login's events, the Combat Log selected and
|
||||
/// deselected, and the dock moved by the bars `UIParent_ManageFramePositions` places, with
|
||||
/// `FCF_OnUpdate` running every frame. The docked tabs sit on one row on ChatFrame1's background
|
||||
/// throughout. The hidden Combat Log's layout waits while it is hidden, and the dock's own reads
|
||||
/// (`MouseIsOver`'s getters in `FCF_OnUpdate`) resolve it each frame (`0x7a1980`), so its tab
|
||||
/// follows the dock.
|
||||
#[test]
|
||||
fn the_docked_tabs_follow_the_dock_through_a_session() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(768.0 * 16.0 / 9.0, 768.0);
|
||||
s.set_texture_size_probe(Box::new(|_| Some((32, 32))));
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
super::lifecycle::finish_ui_load_with(&mut s, |_| {}, super::fire_chat_login);
|
||||
s.fire_world_enter();
|
||||
let frames = |s: &mut UiScript, n: usize| {
|
||||
for _ in 0..n {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
};
|
||||
let check = |s: &mut UiScript, step: &str| {
|
||||
// Hover the dock, so the tabs show (`FCF_OnUpdate`).
|
||||
let (x, y): (f32, f32) = s
|
||||
.eval(
|
||||
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
|
||||
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
|
||||
)
|
||||
.unwrap();
|
||||
s.mouse_move(x, y);
|
||||
frames(s, 45);
|
||||
let row: f32 = s.eval("return ChatFrame1Background:GetTop()").unwrap();
|
||||
let quads = s.extract();
|
||||
for tab in ["ChatFrame1Tab", "ChatFrame2Tab"] {
|
||||
let q = quads
|
||||
.iter()
|
||||
.find(|q| {
|
||||
matches!(q.content, QuadContent::Frame)
|
||||
&& s.target_owner_name(q.target).as_deref() == Some(tab)
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{step}: {tab} draws"));
|
||||
let bottom = q.rect.expect("a placed tab").bottom;
|
||||
assert!(
|
||||
(bottom - row).abs() < 0.01,
|
||||
"{step}: {tab} sits at {bottom}, the dock's row is {row}"
|
||||
);
|
||||
}
|
||||
};
|
||||
let click = |s: &mut UiScript, tab: &str| {
|
||||
s.run(&format!(
|
||||
"this = {tab} FCF_Tab_OnClick('LeftButton') this = nil"
|
||||
))
|
||||
.unwrap();
|
||||
frames(s, 5);
|
||||
};
|
||||
frames(&mut s, 30);
|
||||
check(&mut s, "login");
|
||||
click(&mut s, "ChatFrame2Tab");
|
||||
check(&mut s, "Combat Log selected");
|
||||
click(&mut s, "ChatFrame1Tab");
|
||||
check(&mut s, "General selected");
|
||||
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update() UIParent_ManageFramePositions()")
|
||||
.unwrap();
|
||||
frames(&mut s, 5);
|
||||
check(&mut s, "bottom-left bar");
|
||||
s.run("PetActionBarFrame:Show() UIParent_ManageFramePositions()")
|
||||
.unwrap();
|
||||
frames(&mut s, 5);
|
||||
check(&mut s, "pet bar");
|
||||
click(&mut s, "ChatFrame2Tab");
|
||||
check(&mut s, "Combat Log selected over the pet bar");
|
||||
s.run("PetActionBarFrame:Hide() UIParent_ManageFramePositions()")
|
||||
.unwrap();
|
||||
frames(&mut s, 5);
|
||||
check(&mut s, "pet bar gone");
|
||||
click(&mut s, "ChatFrame1Tab");
|
||||
check(&mut s, "General selected again");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(quality),
|
||||
inventory_type: 0,
|
||||
item_id,
|
||||
link: Some(format!("|cffffffff|Hitem:{item_id}|h[{name}]|h|r")),
|
||||
locked: false,
|
||||
|
||||
@@ -20,6 +20,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
@@ -260,6 +261,7 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -148,6 +148,7 @@ fn fire_sites() -> Vec<(String, Vec<char>, String)> {
|
||||
for call in [
|
||||
concat!("fire_event", "("),
|
||||
concat!("fire_event_into", "("),
|
||||
concat!("model.raise", "("),
|
||||
concat!("pending_events.push", "(("),
|
||||
] {
|
||||
let mut from = 0;
|
||||
@@ -253,6 +254,38 @@ fn every_event_we_fire_carries_the_reference_s_arguments() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The in-game event table (`0x703d90`, name array `0xbe1198`) holds the names of
|
||||
/// `reference/1.12-event-catalog.tsv` and the glue table the 26 of `GLUE_EVENTS`; `RegisterEvent`
|
||||
/// drops any other name (`0x702140`), so an event fired under a name off both reaches no frame
|
||||
/// that registered it by name.
|
||||
#[test]
|
||||
fn every_event_we_fire_is_in_the_reference_table() {
|
||||
let tsv = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../reference/1.12-event-catalog.tsv"
|
||||
);
|
||||
let text = std::fs::read_to_string(tsv).expect("reference/1.12-event-catalog.tsv");
|
||||
let names: std::collections::BTreeSet<&str> = text
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.filter_map(|l| l.split_once('\t').map(|(_, n)| n))
|
||||
.chain(benilla_ui::script::GLUE_EVENTS)
|
||||
.collect();
|
||||
assert!(names.len() > 390, "the catalog stopped being read");
|
||||
let mut off: Vec<String> = fire_sites()
|
||||
.into_iter()
|
||||
.filter(|(name, _, _)| !names.contains(name.as_str()))
|
||||
.map(|(name, _, at)| format!("{name} from {at}"))
|
||||
.collect();
|
||||
off.sort();
|
||||
off.dedup();
|
||||
assert!(
|
||||
off.is_empty(),
|
||||
"fired under a name the reference's event table lacks:\n {}",
|
||||
off.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x51bbb0` registers one watch per named unit-window field, so each of the 25 named event ids
|
||||
/// below `0xb6` comes from that bridge with the unit token, and each needs a producer here.
|
||||
#[test]
|
||||
|
||||
@@ -97,7 +97,6 @@ fn content_kind(c: &QuadContent) -> &'static str {
|
||||
QuadContent::Texture { .. } => "texture",
|
||||
QuadContent::ColorWheel => "colorwheel",
|
||||
QuadContent::ColorValue { .. } => "colorvalue",
|
||||
QuadContent::Backdrop { .. } => "backdrop",
|
||||
QuadContent::Text { .. } => "text",
|
||||
}
|
||||
}
|
||||
@@ -115,8 +114,9 @@ pub(crate) fn tiling_axes(uv: &UvRect) -> (bool, bool) {
|
||||
}
|
||||
|
||||
/// The [`UiQuad::uv_clamp`] window of an atlas crop, inset half a texel so a magnified cell does
|
||||
/// not filter in its neighbour. A tiling axis is left alone, as
|
||||
/// [`benilla_ui::script::inset_atlas_bleed`] does; a whole-texture axis needs no window.
|
||||
/// not filter in its neighbour (in `ChatBubble-Backdrop`, a white alpha-0 column: a pale line
|
||||
/// through the speech bubble). A tiling axis is left alone, or each period would land short and
|
||||
/// walk the art; a whole-texture axis needs no window.
|
||||
fn uv_clamp_window(uv: &UvRect, size: (u32, u32)) -> Option<[f32; 4]> {
|
||||
let mut out = [1.0, 1.0, 0.0, 0.0]; // both axes off: `min > max`
|
||||
let mut any = false;
|
||||
@@ -280,7 +280,6 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
|
||||
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
|
||||
match &eq.content {
|
||||
QuadContent::Frame
|
||||
| QuadContent::Backdrop { .. }
|
||||
// At most one quad and one idempotent tile request.
|
||||
| QuadContent::ModelPane { .. }
|
||||
| QuadContent::ColorWheel
|
||||
@@ -490,18 +489,28 @@ pub(super) fn tick_script(
|
||||
{
|
||||
let _span = bevy::log::info_span!("ui_script: tick").entered();
|
||||
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
|
||||
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
|
||||
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
|
||||
if resized {
|
||||
let display = (w.round() as u32, h.round() as u32);
|
||||
let mut none = None;
|
||||
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
|
||||
script.apply_ui_scale(display, cache);
|
||||
}
|
||||
let rescaled = script.set_ui_scale(ui_scale.0);
|
||||
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
|
||||
if resized || rescaled {
|
||||
super::manifest::on_screen_resized(&script);
|
||||
// The UI scale is `UIParent`'s, which only the in-game state has; the glue lays out on
|
||||
// the root alone (`0x46dd30`).
|
||||
if script.vm_kind() == benilla_ui::script::VmKind::InGame {
|
||||
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap
|
||||
// follows the window; a moved dial applies once, as its CVar callback does
|
||||
// (`0x490770`). Deviation: the reference then re-reads the aspect (`0x492f13`), and on
|
||||
// a change of 0.001 or more calls `0x491380`, which sets the reload trigger
|
||||
// (`[0xb4b3f4]`) when the script-context check `0x494a50(0xa)` allows it; here a
|
||||
// resize re-lays out the live UI, the chat bubbles' window-derived `BubbleGeometry`
|
||||
// with it, because the window resizes freely and a reload on each resize would tear
|
||||
// down every addon's state mid-play.
|
||||
if resized {
|
||||
let display = (w.round() as u32, h.round() as u32);
|
||||
let mut none = None;
|
||||
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
|
||||
script.apply_ui_scale(display, cache);
|
||||
}
|
||||
let rescaled = script.set_ui_scale(ui_scale.0);
|
||||
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
|
||||
if resized || rescaled {
|
||||
super::manifest::on_screen_resized(&script);
|
||||
}
|
||||
}
|
||||
script.tick(time.delta_secs());
|
||||
}
|
||||
@@ -514,7 +523,8 @@ pub(super) fn tick_script(
|
||||
let us_tick = lap();
|
||||
// ── The FontString measure round-trip, before the resolve ────────────────────────────────
|
||||
// A request reads only region data, never a resolved rect, and `resolve` writes no region's
|
||||
// size, so measuring first lets the frame's one resolve see the answers.
|
||||
// size, so measuring first lets the resolve below see the answers. The tick drained the layout
|
||||
// after its walk; this resolve takes the measures and what the tick's tail moved.
|
||||
let mut measured_any = false;
|
||||
if let Some(atlas) = font_atlas.as_deref_mut() {
|
||||
measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0);
|
||||
@@ -550,6 +560,9 @@ pub(super) fn tick_script(
|
||||
path: r.font.as_deref(),
|
||||
height: crate::ui_text::drawn_px(r.height, None, s * r.scale),
|
||||
outline: r.outline, // the outline biases the stepped width
|
||||
spacing: 0.0, // the gap moves rows, never counts them
|
||||
// The drawn shadow seeds each row, as the draw's does.
|
||||
shadow_x: r.shadow_x * s * r.scale,
|
||||
alpha_gradient: None,
|
||||
},
|
||||
);
|
||||
@@ -570,8 +583,13 @@ pub(super) fn tick_script(
|
||||
for w in script.take_warnings() {
|
||||
warn!("ui_script: {w}");
|
||||
}
|
||||
// A runtime `LoadAddOn`'s record, appended to `Logs\FrameXML.log`.
|
||||
super::load_log::write(script.take_load_log_writes());
|
||||
// A runtime `LoadAddOn`'s record, appended to `Logs\FrameXML.log`; the glue has none, but
|
||||
// whatever its VM drains is the glue log's.
|
||||
let writes = script.take_load_log_writes();
|
||||
match script.vm_kind() {
|
||||
benilla_ui::script::VmKind::InGame => super::load_log::write(writes),
|
||||
benilla_ui::script::VmKind::Glue => super::load_log::write_glue(writes),
|
||||
}
|
||||
|
||||
// ── The handover ────────────────────────────────────────────────────────────────────────
|
||||
*pass = super::UiPassState {
|
||||
@@ -1122,8 +1140,9 @@ fn convert_entry(
|
||||
});
|
||||
}
|
||||
// A `<Model>`/`<PlayerModel>` pane draws itself, as the stock files declare a bare pane
|
||||
// with no Texture: a file pane is a model tile, a unit pane samples its window's body
|
||||
// bake square ([`crate::portrait::model_pane_booth`]), and an unclaimed pane draws nothing.
|
||||
// with no Texture: a glue `ModelFFX` is the glue booth's scene, a file pane is a model
|
||||
// tile, a unit pane samples its window's body bake square
|
||||
// ([`crate::portrait::model_pane_booth`]), and an unclaimed pane draws nothing.
|
||||
QuadContent::ModelPane {
|
||||
handle,
|
||||
name,
|
||||
@@ -1136,8 +1155,41 @@ fn convert_entry(
|
||||
camera,
|
||||
light,
|
||||
fog,
|
||||
ffx,
|
||||
} => {
|
||||
use crate::portrait::PortraitSource;
|
||||
// A `ModelFFX`'s scene draws once, at its own bucket's ARTWORK callback (`0x46fa90`
|
||||
// overrides the paint registration `0x76d160`), so every UI region paints over it.
|
||||
// The scene is the glue booth's, which renders the window's view of the stage the
|
||||
// glue VM feeds it (`crate::ui_script::glue_scene`); the pane shows its part of it.
|
||||
if ffx {
|
||||
let Some(PortraitSource::Live(image)) =
|
||||
booths.images.0.get(crate::portrait::GLUE_SLOT)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
out.push(UiQuad {
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
texture: Some(image.clone()),
|
||||
uv: UvRect::from_tex_coords([
|
||||
rect.min.x / w,
|
||||
rect.max.x / w,
|
||||
rect.min.y / h,
|
||||
rect.max.y / h,
|
||||
]),
|
||||
// The scene draws at the widget's own alpha (`0x76d120`).
|
||||
color: [1.0, 1.0, 1.0, own_alpha],
|
||||
// The booth's target is premultiplied.
|
||||
premultiplied: true,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A file pane publishes an idempotent tile request; `ui_models::compose_tiles` draws
|
||||
// its quad each frame, so a cell packed later needs no re-conversion.
|
||||
if let Some(path) = model.as_deref() {
|
||||
@@ -1404,44 +1456,6 @@ fn convert_entry(
|
||||
..default()
|
||||
});
|
||||
}
|
||||
QuadContent::Backdrop {
|
||||
path,
|
||||
color,
|
||||
uvs,
|
||||
tile,
|
||||
} => {
|
||||
// A backdrop piece, the bg or one of the 8 border pieces, sampled `Repeat` when `tile`;
|
||||
// its UVs are per-corner, as the TOP and BOTTOM edges are rotated.
|
||||
let handle = assets.as_mut().and_then(|a| {
|
||||
if tile {
|
||||
a.sprite_texture_tiled(&path, images)
|
||||
} else {
|
||||
a.sprite_texture(&path, images)
|
||||
}
|
||||
});
|
||||
// A missing BLP draws nothing.
|
||||
let Some(handle) = handle else { return };
|
||||
// The 8 border pieces share one atlas, so a `tile` piece is pulled half a texel in on
|
||||
// its bounded axes; a stretched bg is a whole texture.
|
||||
let uvs = match images.get(&handle) {
|
||||
Some(img) if tile => {
|
||||
let sz = img.texture_descriptor.size;
|
||||
benilla_ui::script::inset_atlas_bleed(uvs, sz.width as f32, sz.height as f32)
|
||||
}
|
||||
_ => uvs,
|
||||
};
|
||||
let mut color = color;
|
||||
color[3] *= eq.alpha;
|
||||
out.push(UiQuad {
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
texture: Some(handle),
|
||||
uv: UvRect::from_corners(uvs),
|
||||
color,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
QuadContent::Text {
|
||||
text,
|
||||
color,
|
||||
@@ -1452,6 +1466,7 @@ fn convert_entry(
|
||||
text_height,
|
||||
shadow,
|
||||
outline,
|
||||
spacing,
|
||||
alpha_gradient,
|
||||
world_seat,
|
||||
} => {
|
||||
@@ -1471,6 +1486,7 @@ fn convert_entry(
|
||||
text_height,
|
||||
shadow,
|
||||
outline,
|
||||
spacing,
|
||||
alpha_gradient,
|
||||
world_seat,
|
||||
},
|
||||
@@ -1957,6 +1973,11 @@ mod extract_gate_tests {
|
||||
fn app_from_script(lua: &str) -> App {
|
||||
let script = UiScript::new().unwrap();
|
||||
script.run(lua).unwrap();
|
||||
app_over(script)
|
||||
}
|
||||
|
||||
/// [`app_from_script`] over a VM of the caller's.
|
||||
fn app_over(script: UiScript) -> App {
|
||||
let mut app = App::new();
|
||||
app.insert_non_send_resource(script);
|
||||
app.init_resource::<UiQuads>();
|
||||
@@ -2033,6 +2054,101 @@ mod extract_gate_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A glue `ModelFFX` draws the glue booth's image over its own rect, sampling the window's
|
||||
/// part of the booth's view, at its bucket's callback rank, and asks the UI model tiles for
|
||||
/// nothing; a plain `Model` beside it is still a tile.
|
||||
#[test]
|
||||
fn a_model_ffx_draws_the_glue_booth_and_asks_for_no_tile() {
|
||||
let script = UiScript::new_glue().unwrap();
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
local holder = CreateFrame("Frame", "Holder")
|
||||
holder:SetPoint("TOPLEFT", 0, 0); holder:SetWidth(1024); holder:SetHeight(768)
|
||||
holder:SetAlpha(0.5)
|
||||
local scene = CreateFrame("ModelFFX", "Scene", holder)
|
||||
scene:SetPoint("TOPLEFT", 100, -50)
|
||||
scene:SetWidth(400); scene:SetHeight(300)
|
||||
scene:SetModel("Interface\\Glues\\Models\\UI_Orc\\UI_Orc.mdx")
|
||||
scene:SetAlpha(0.8)
|
||||
local art = scene:CreateTexture(nil, "ARTWORK")
|
||||
art:SetAllPoints(scene); art:SetTexture(1, 0, 0)
|
||||
local over = scene:CreateTexture(nil, "OVERLAY")
|
||||
over:SetAllPoints(scene); over:SetTexture(0, 1, 0)
|
||||
local child = CreateFrame("Frame", "Child", scene)
|
||||
child:SetAllPoints(scene)
|
||||
local under = child:CreateTexture(nil, "BACKGROUND")
|
||||
under:SetAllPoints(child); under:SetTexture(0, 0, 1)
|
||||
local plain = CreateFrame("Model", "Plain")
|
||||
plain:SetPoint("TOPLEFT", 600, -50)
|
||||
plain:SetWidth(64); plain:SetHeight(64)
|
||||
plain:SetModel("Plain.mdx")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = app_over(script);
|
||||
let image = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Image>>()
|
||||
.add(Image::default());
|
||||
app.world_mut().resource_mut::<PortraitImages>().0.insert(
|
||||
crate::portrait::GLUE_SLOT.to_string(),
|
||||
crate::portrait::PortraitSource::Live(image.clone()),
|
||||
);
|
||||
app.update();
|
||||
|
||||
let paths: Vec<String> = app
|
||||
.world()
|
||||
.resource::<crate::ui_models::UiModelTiles>()
|
||||
.requests
|
||||
.values()
|
||||
.map(|r| r.path.clone())
|
||||
.collect();
|
||||
assert_eq!(paths, ["Plain.mdx"], "the ModelFFX is no tile");
|
||||
|
||||
let quads = &app.world().resource::<UiQuads>().quads;
|
||||
let scene: Vec<_> = quads
|
||||
.iter()
|
||||
.filter(|q| q.texture.as_ref() == Some(&image))
|
||||
.collect();
|
||||
assert_eq!(scene.len(), 1, "the pane draws the glue booth's image once");
|
||||
let q = scene[0];
|
||||
assert!(q.premultiplied);
|
||||
// The widget's own alpha (`0x76d120`); `SetAlpha` writes the raw value down the subtree
|
||||
// with no product, so the holder's earlier 0.5 is gone.
|
||||
assert!((q.color[3] - 0.8).abs() < 1e-3, "{:?}", q.color);
|
||||
// The ARTWORK callback of the pane's own bucket: after its ARTWORK regions, before its
|
||||
// OVERLAY ones and every child frame's (`0x46fa90`, layer 2).
|
||||
let z_of = |rgb: [f32; 3]| {
|
||||
quads
|
||||
.iter()
|
||||
.find(|q| q.texture.is_none() && q.color[..3] == rgb)
|
||||
.map(|q| q.z_key)
|
||||
.expect("the region drew")
|
||||
};
|
||||
let (art, over, child) = (
|
||||
z_of([1.0, 0.0, 0.0]),
|
||||
z_of([0.0, 1.0, 0.0]),
|
||||
z_of([0.0, 0.0, 1.0]),
|
||||
);
|
||||
assert!(
|
||||
art < q.z_key && q.z_key < over && over < child,
|
||||
"{art} {} {over} {child}",
|
||||
q.z_key
|
||||
);
|
||||
assert!(q.rect.width() > 0.0 && q.rect.height() > 0.0);
|
||||
let (w, h) = (1024.0, 768.0);
|
||||
assert!(
|
||||
q.uv == super::UvRect::from_tex_coords([
|
||||
q.rect.min.x / w,
|
||||
q.rect.max.x / w,
|
||||
q.rect.min.y / h,
|
||||
q.rect.max.y / h,
|
||||
]),
|
||||
"the pane samples its own part of the window's view"
|
||||
);
|
||||
}
|
||||
|
||||
/// `PaperDollFrame.xml:204` declares a bare `<PlayerModel name="CharacterModelFrame">`; a pane
|
||||
/// no window claims ([`crate::portrait::model_pane_booth`]) must not borrow a bake.
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,8 @@ pub(super) struct TextStyle {
|
||||
pub text_height: Option<f32>,
|
||||
pub shadow: Option<FontShadow>,
|
||||
pub outline: Outline,
|
||||
/// The gap between lines, frame-local, scaled like the font height.
|
||||
pub spacing: f32,
|
||||
pub alpha_gradient: Option<(f32, f32)>,
|
||||
/// The V-plate's name and level, seated on the plate's device-snapped overlay, not the UI grid.
|
||||
pub world_seat: bool,
|
||||
@@ -76,6 +78,11 @@ pub(super) fn emit(
|
||||
host.scale * host.font_scale,
|
||||
),
|
||||
outline: style.outline,
|
||||
spacing: style.spacing * host.scale * host.font_scale,
|
||||
// A set shadow seeds each row's pen at its drawn offset (`0x5cddb1`, `0x7726a4`).
|
||||
shadow_x: style
|
||||
.shadow
|
||||
.map_or(0.0, |sh| sh.offset[0] * host.scale * host.font_scale),
|
||||
alpha_gradient: style.alpha_gradient,
|
||||
};
|
||||
// The focused editbox draws windowed (`0x77da80`): the scroll window's substring from the
|
||||
|
||||
@@ -337,6 +337,7 @@ fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run("AddonScroll:UpdateScrollChildRect()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return AddonScroll:GetVerticalScrollRange()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -253,7 +253,8 @@ fn reference_takes_mouse(name: &str, frames: &HashMap<String, Element>) -> bool
|
||||
if tag_mouse_enabled_by_ctor(&el.tag) {
|
||||
return true;
|
||||
}
|
||||
if resolved_attr(name, frames, "enableMouse", 0).is_some_and(|v| v.eq_ignore_ascii_case("true"))
|
||||
if resolved_attr(name, frames, "enableMouse", 0)
|
||||
.is_some_and(benilla_ui::strings::string_to_bool)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -347,7 +348,7 @@ fn the_shipped_frames_carry_the_references_flags() {
|
||||
compared += 1;
|
||||
|
||||
let want_toplevel = resolved_attr(theirs, &reference, "toplevel", 0)
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("true"));
|
||||
.is_some_and(benilla_ui::strings::string_to_bool);
|
||||
let want_mouse = reference_takes_mouse(theirs, &reference);
|
||||
let want_id: i64 = resolved_attr(theirs, &reference, "id", 0)
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
|
||||
@@ -749,8 +749,11 @@ fn the_who_list_reaches_its_last_row_at_the_bars_end() {
|
||||
.eval::<(f64, f64)>("return WhoListScrollFrameScrollBar:GetMinMaxValues()")
|
||||
.unwrap();
|
||||
assert_eq!(bar_max, 512.0, "(49 − 17) × 16");
|
||||
// The overflow as geometry: the stored range is not re-measured for a child's resize.
|
||||
let overflow = s
|
||||
.eval::<f64>("return WhoListScrollFrame:GetVerticalScrollRange()")
|
||||
.eval::<f64>(
|
||||
"return WhoListScrollFrameScrollChildFrame:GetHeight() - WhoListScrollFrame:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
overflow, 497.0,
|
||||
|
||||
@@ -702,6 +702,7 @@ fn backpack() -> ContainerState {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
inventory_type: 0,
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! The stock glue screens, behind `WOW_GLUE_STOCK=1` in a dev build: the player's own
|
||||
//! `GlueXML.toc` off the patch chain, run into a glue VM ([`UiScript::new_glue`]) that stands in
|
||||
//! the world while no character is in it, where the boot VM otherwise stands. The native screens
|
||||
//! stay the default and, under the switch, keep driving the network unseen.
|
||||
//!
|
||||
//! The reference's glue builder (`0x46a7b0`) resets the Lua state on a rebuild (`0x46a87b`; the
|
||||
//! cold first build, `0x46a505`, passes 0 and skips it), registers the glue functions
|
||||
//! (`0x46abb0`) and the 26 glue events (`0x46a88f`), opens `Logs\GlueXML.log` (`0x46a8ce`), then
|
||||
//! checks `GlueXML.toc`'s signature (`0x46a90b`). Only a valid one (`0x6f10f0` answering 3) loads
|
||||
//! the toc through the shared runner (`0x6edb90`, at `0x46a9b3`), and only when the loaded files'
|
||||
//! MD5 matches the signature's (`0x46a9d3`) does `FRAMES_LOADED` fire (`0x46aa34`). Signature
|
||||
//! results 0–2 (the table at `0x46aaac`) log their line, missing, corrupt or modified (`0x836fb8`,
|
||||
//! `0x836f98`, `0x836f78`), and load nothing; an MD5 mismatch logs no line, its frames already
|
||||
//! built, and skips `FRAMES_LOADED`. Either way the client quits with
|
||||
//! error 9 (`0x401560`: the exit code, then the quit post `ForceQuit` makes with code 0).
|
||||
//! Deviation: no signature or digest check (`0x46a90b`, `0x46a9d3`, `0x401560(9)`), so the chain's
|
||||
//! toc always loads and `FRAMES_LOADED` follows, because the check verifies Blizzard's signature
|
||||
//! with a key carried in the client binary, which benilla does not ship, and a modified interface
|
||||
//! file is loaded rather than refused.
|
||||
//!
|
||||
//! The screen comes after the build: at startup the reference fires `SET_GLUE_SCREEN` with
|
||||
//! `"movie"` while the `movie` CVar is 1 (clearing it), else `"login"` (`0x402b3b`–`0x402b65`);
|
||||
//! back from the world `0x402090` fires it with `"charselect"` or `"login"`. benilla plays no movie
|
||||
//! yet (#968), so its boot screen is `"login"`.
|
||||
//!
|
||||
//! The screens' `ModelFFX` panes drive the glue booth ([`super::glue_scene`]): its scene rig, the
|
||||
//! scene M2's own point lights and the `FfxGlow::GLUE_SCENE` pass, never the `ui_models` tile
|
||||
//! path, where every lit batch draws black.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
|
||||
use super::reference_ui;
|
||||
|
||||
/// The switch's variable.
|
||||
const SWITCH: &str = "WOW_GLUE_STOCK";
|
||||
|
||||
/// Whether this run puts the stock glue screens up, read once at plugin build: a dev build with
|
||||
/// `WOW_GLUE_STOCK=1`. A player build never does.
|
||||
#[derive(Resource, Default, Clone, Copy)]
|
||||
pub(crate) struct GlueStock(pub(crate) bool);
|
||||
|
||||
impl GlueStock {
|
||||
/// The switch as this process has it.
|
||||
pub(crate) fn from_env() -> Self {
|
||||
Self(
|
||||
crate::run_mode::dev_affordances()
|
||||
&& std::env::var(SWITCH).ok().as_deref() == Some("1"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run condition: the stock glue screens are up, the switch on and no character in the world.
|
||||
pub(crate) fn glue_ui_up(
|
||||
stock: Option<Res<GlueStock>>,
|
||||
state: Option<Res<State<crate::char_select::ClientState>>>,
|
||||
) -> bool {
|
||||
stock.is_some_and(|s| s.0)
|
||||
&& state.is_none_or(|s| *s.get() != crate::char_select::ClientState::InWorld)
|
||||
}
|
||||
|
||||
/// Run condition: the switch is on, whether or not a character is in the world.
|
||||
pub(crate) fn stock_glue(stock: Option<Res<GlueStock>>) -> bool {
|
||||
stock.is_some_and(|s| s.0)
|
||||
}
|
||||
|
||||
/// Run condition: the native glue screens draw and take input, the switch off.
|
||||
pub(crate) fn native_glue(stock: Option<Res<GlueStock>>) -> bool {
|
||||
!stock.is_some_and(|s| s.0)
|
||||
}
|
||||
|
||||
/// The glue screen `SET_GLUE_SCREEN` names, `GlueParent.lua`'s `GlueScreenInfo` key.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GlueScreen {
|
||||
Login,
|
||||
CharSelect,
|
||||
}
|
||||
|
||||
impl GlueScreen {
|
||||
/// The key, as the reference's literals spell it (`0x82e3e0`, `0x82e3e8`).
|
||||
pub(crate) fn key(self) -> &'static str {
|
||||
match self {
|
||||
GlueScreen::Login => "login",
|
||||
GlueScreen::CharSelect => "charselect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the chain's `GlueXML.toc` into `script`, a glue VM, and fire `FRAMES_LOADED`: the glue
|
||||
/// builder's steps from the toc on (`0x46a9b3`, `0x46aa34`). Returns every failure; a loader error
|
||||
/// is tagged `"GlueXML/<file>: <error>"`, a handler that raised during the walk is one too. The
|
||||
/// load's record is left for [`finish_glue_load_log`].
|
||||
pub(super) fn load_glue(script: &mut UiScript) -> Vec<String> {
|
||||
let mut failures = match reference_ui::glue() {
|
||||
Some(glue) => {
|
||||
let mut toc = benilla_ui::status::Status::default();
|
||||
let failures = glue.load_files_into(script, &glue.toc.files, &mut toc);
|
||||
let mut log = benilla_ui::status::Status::default();
|
||||
let banner = benilla_ui::status::toc_banner(reference_ui::GLUE_TOC);
|
||||
toc.close_into(&mut log, script.framexml_debug(), banner);
|
||||
script.report_load_status(log);
|
||||
failures
|
||||
}
|
||||
None => {
|
||||
// The runner's `"Couldn't open %s"` (`0x846ff4`) at severity 2; no glue frame exists.
|
||||
let e = format!(
|
||||
"{}: not found — the stock glue screens are not built",
|
||||
reference_ui::GLUE_TOC
|
||||
);
|
||||
error!("ui_script: {e}");
|
||||
script.report_load_failure(&e);
|
||||
let mut log = benilla_ui::status::Status::default();
|
||||
log.report(
|
||||
benilla_ui::status::FAILURE,
|
||||
benilla_ui::status::missing(reference_ui::GLUE_TOC, false),
|
||||
);
|
||||
script.report_load_status(log);
|
||||
vec![e]
|
||||
}
|
||||
};
|
||||
if super::manifest::layer_enabled() {
|
||||
failures.extend(load_glue_layer(script));
|
||||
}
|
||||
script.fire_event("FRAMES_LOADED", vec![]);
|
||||
failures.extend(
|
||||
script
|
||||
.errors()
|
||||
.into_iter()
|
||||
.map(|e| format!("a handler raised during the glue load: {e}")),
|
||||
);
|
||||
failures
|
||||
}
|
||||
|
||||
/// benilla's one glue file, its realmlist control over the stock `AccountLogin`, which loads after
|
||||
/// every `GlueXML.toc` row and before `FRAMES_LOADED`, as the in-game layer loads after
|
||||
/// `FrameXML.toc` ([`super::manifest::LAYER_MANIFEST`]); `WOW_STOCK_UI=1` drops it with that layer.
|
||||
pub(super) const GLUE_LAYER: &str = "AccountLoginRealmlist.xml";
|
||||
|
||||
/// Load [`GLUE_LAYER`] off the shipped tree; returns its failures.
|
||||
fn load_glue_layer(script: &UiScript) -> Vec<String> {
|
||||
let files = vec![GLUE_LAYER.to_string()];
|
||||
let layer = super::addons::Addon::new(
|
||||
"benilla".to_string(),
|
||||
benilla_ui::toc::Toc {
|
||||
directives: Vec::new(),
|
||||
files: files.clone(),
|
||||
},
|
||||
super::addons::Source::Builtin,
|
||||
);
|
||||
layer.load_files(script, &files)
|
||||
}
|
||||
|
||||
/// Drain the glue load's record into `Logs\GlueXML.log`, as the builder does after
|
||||
/// `FRAMES_LOADED` (`0x46aa61`-`0x46aa94`).
|
||||
pub(super) fn finish_glue_load_log(script: &mut UiScript) {
|
||||
script.finish_ui_load_log();
|
||||
super::load_log::write_glue(script.take_load_log_writes());
|
||||
}
|
||||
|
||||
/// Put `screen` up: `SET_GLUE_SCREEN` with its key as `arg1` (`0x46b840`, event 0).
|
||||
pub(super) fn set_glue_screen(script: &mut UiScript, screen: GlueScreen) {
|
||||
script.fire_event(
|
||||
"SET_GLUE_SCREEN",
|
||||
vec![ScriptValue::Str(screen.key().to_string())],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The glue functions the stock GlueXML calls that benilla has not built yet: each one's call
|
||||
/// raises "attempt to call global `X' (a nil value)", at the load or on the login screen. The
|
||||
/// list only shrinks: a name built, or no longer reached, comes off it.
|
||||
const UNBUILT_CALLED: &[&str] = &["SetCharCustomizeFrame"];
|
||||
|
||||
/// What the player's own files make the reference report too: their `tos.html` uses `<H4>`,
|
||||
/// which 1.12's SimpleHTML does not know (`"Frame %s: Unknown element type: %s"`,
|
||||
/// `0x87a95c`, and the walk goes on).
|
||||
const INSTALL_WARNINGS: &[&str] = &["Frame TOSText: Unknown element type: H4"];
|
||||
|
||||
/// The frames each stock glue screen and dialog is, which the load must build.
|
||||
const SCREENS: [&str; 8] = [
|
||||
"GlueParent",
|
||||
"AccountLogin",
|
||||
"CharacterSelect",
|
||||
"CharacterCreate",
|
||||
"RealmList",
|
||||
"GlueDialog",
|
||||
"RealmWizard",
|
||||
"AddonList",
|
||||
];
|
||||
|
||||
/// The name a "nil value" call error names, in either of Lua's two spellings of it.
|
||||
fn nil_call(error: &str) -> Option<&str> {
|
||||
let (_, rest) = error.split_once("attempt to call ")?;
|
||||
let (kind, rest) = rest.split_once(' ')?;
|
||||
if !matches!(kind, "global" | "field" | "method") {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.strip_prefix('`').or_else(|| rest.strip_prefix('\''))?;
|
||||
let (name, rest) = rest.split_once('\'')?;
|
||||
rest.starts_with(" (a nil value)").then_some(name)
|
||||
}
|
||||
|
||||
/// The glue state's functions, `reference/1.12-glue-surface.tsv`'s names.
|
||||
fn glue_surface() -> BTreeSet<String> {
|
||||
let path = format!(
|
||||
"{}/../../reference/1.12-glue-surface.tsv",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("reading {path}: {e}"))
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.starts_with("scope\t"))
|
||||
.filter_map(|l| l.split('\t').nth(2).map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nil_call_names_its_function() {
|
||||
assert_eq!(
|
||||
nil_call("x.lua:1: attempt to call global `GetBuildInfo' (a nil value)"),
|
||||
Some("GetBuildInfo")
|
||||
);
|
||||
assert_eq!(
|
||||
nil_call("x.lua:1: attempt to call field 'Foo' (a nil value)"),
|
||||
Some("Foo")
|
||||
);
|
||||
assert_eq!(
|
||||
nil_call("x.lua:1: attempt to index global `X' (a nil value)"),
|
||||
None
|
||||
);
|
||||
assert_eq!(nil_call("x.lua:1: bad argument #1"), None);
|
||||
}
|
||||
|
||||
/// The stock GlueXML loads into the glue VM, `FRAMES_LOADED` fires and the login screen goes
|
||||
/// up, and a few frames run: every screen's frame exists, and the only Lua errors are calls
|
||||
/// of the glue functions [`UNBUILT_CALLED`] names, each of which is still unbuilt and still
|
||||
/// called. Any other error, an XML problem or a missing widget method, fails here.
|
||||
#[test]
|
||||
fn the_stock_glue_loads_with_only_unbuilt_verbs_raising() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new_glue().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let mut errors = load_glue(&mut s);
|
||||
// `load_glue` reports the walk's handler errors; the rest are the screen's and the ticks'.
|
||||
let _ = s.take_errors();
|
||||
set_glue_screen(&mut s, GlueScreen::Login);
|
||||
for _ in 0..3 {
|
||||
s.tick(0.1);
|
||||
}
|
||||
s.resolve();
|
||||
errors.extend(s.take_errors());
|
||||
// Every row the session retained: the load's failures and warnings, and what raised.
|
||||
errors.extend(s.diagnostics().into_iter().map(|d| d.message));
|
||||
|
||||
let surface = glue_surface();
|
||||
let mut called = BTreeSet::new();
|
||||
let other: Vec<&String> = errors
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
if let Some(name) = nil_call(e).filter(|n| UNBUILT_CALLED.contains(n)) {
|
||||
called.insert(name);
|
||||
return false;
|
||||
}
|
||||
if INSTALL_WARNINGS.contains(&e.as_str()) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
other.is_empty(),
|
||||
"the glue load raised {} error(s) or warning(s) that are not a call of an unbuilt \
|
||||
glue function on UNBUILT_CALLED:\n{}",
|
||||
other.len(),
|
||||
other
|
||||
.iter()
|
||||
.map(|e| e.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
let listed: BTreeSet<&str> = UNBUILT_CALLED.iter().copied().collect();
|
||||
let stale: Vec<&&str> = listed.difference(&called).collect();
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"UNBUILT_CALLED names no longer called unbuilt; take them off: {stale:?}"
|
||||
);
|
||||
for name in UNBUILT_CALLED {
|
||||
assert!(
|
||||
surface.contains(*name),
|
||||
"{name} is not a glue-state function"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return {name} == nil")).unwrap(),
|
||||
"{name} is built; take it off UNBUILT_CALLED"
|
||||
);
|
||||
}
|
||||
|
||||
for name in SCREENS {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return type({name}) == 'table' and {name}.GetObjectType ~= nil"
|
||||
))
|
||||
.unwrap(),
|
||||
"the glue load built no frame {name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<(bool, bool)>(
|
||||
"return AccountLogin:IsShown() == 1, CharacterSelect:IsShown() == 1"
|
||||
)
|
||||
.unwrap(),
|
||||
(true, false),
|
||||
"SET_GLUE_SCREEN \"login\" shows the login screen alone"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GlueParent` fills the screen root, 768 tall and `768·a` wide (`0x41ad10`, from `0x46ab40`),
|
||||
/// up to 16:9; past that `GlueParent_OnLoad` centres it 16:9 wide on the root's height.
|
||||
#[test]
|
||||
fn glue_parent_fills_the_root_up_to_sixteen_by_nine() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let rect = |aspect: f32| {
|
||||
let mut s = UiScript::new_glue().unwrap();
|
||||
s.set_screen_size(768.0 * aspect, 768.0);
|
||||
let _ = load_glue(&mut s);
|
||||
s.resolve();
|
||||
s.eval::<(f32, f32, f32, f32)>(
|
||||
"return GlueParent:GetLeft(), GlueParent:GetRight(), GlueParent:GetBottom(), \
|
||||
GlueParent:GetTop()",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let near = |a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)| {
|
||||
[(a.0, b.0), (a.1, b.1), (a.2, b.2), (a.3, b.3)]
|
||||
.iter()
|
||||
.all(|(x, y)| (x - y).abs() < 0.01)
|
||||
};
|
||||
for aspect in [4.0 / 3.0, 16.0 / 10.0, 16.0 / 9.0] {
|
||||
let got = rect(aspect);
|
||||
assert!(
|
||||
near(got, (0.0, 768.0 * aspect, 0.0, 768.0)),
|
||||
"{aspect}: {got:?}"
|
||||
);
|
||||
}
|
||||
let wide = 21.0 / 9.0;
|
||||
let bar = (768.0 * wide - 768.0 * 16.0 / 9.0) / 2.0;
|
||||
let got = rect(wide);
|
||||
assert!(
|
||||
near(got, (bar, 768.0 * wide - bar, 0.0, 768.0)),
|
||||
"21:9: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Keys on the stock glue screens, end to end: the host's key and char events into the stock
|
||||
//! GlueXML in the glue VM.
|
||||
|
||||
use benilla_ui::script::{GlueNotice, NoticeState, UiScript};
|
||||
|
||||
use super::glue::{load_glue, set_glue_screen, GlueScreen};
|
||||
|
||||
/// The stock glue VM on `screen` with both agreements accepted, as a returning player's is.
|
||||
fn glue_on(screen: GlueScreen) -> UiScript {
|
||||
let mut s = UiScript::new_glue().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let accepted = NoticeState {
|
||||
accepted: true,
|
||||
show_notice: false,
|
||||
};
|
||||
for notice in [GlueNotice::Eula, GlueNotice::Tos] {
|
||||
s.set_glue_notice(notice, accepted);
|
||||
}
|
||||
let _ = load_glue(&mut s);
|
||||
set_glue_screen(&mut s, screen);
|
||||
s.resolve();
|
||||
let _ = s.take_errors();
|
||||
s
|
||||
}
|
||||
|
||||
/// One typed key as the host feeds it (`ui_script::input`): the key-down by name, then its char.
|
||||
fn type_key(s: &mut UiScript, name: &str, text: &str) {
|
||||
s.frame_key_input(name);
|
||||
s.char_input(text);
|
||||
s.key_up(name);
|
||||
}
|
||||
|
||||
fn text(s: &UiScript, frame: &str) -> String {
|
||||
s.eval::<String>(&format!("return {frame}:GetText() or ''"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The login boxes take the keys typed into them. `AccountLogin` is keyboard-enabled with an
|
||||
/// `OnChar` and an `OnKeyDown`, and walks ahead of any box that is not itself in the walk; the
|
||||
/// EditBox ctor enables key input (`0x779ced`, `0x779cf8`) so the focused box answers at its own
|
||||
/// level above its parent.
|
||||
#[test]
|
||||
fn the_stock_login_boxes_take_typed_keys() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = glue_on(GlueScreen::Login);
|
||||
assert!(
|
||||
s.eval::<bool>("return AccountLoginUI:IsShown() == 1")
|
||||
.unwrap(),
|
||||
"accepted agreements put the login boxes up"
|
||||
);
|
||||
s.run("AccountLoginAccountEdit:SetFocus()").unwrap();
|
||||
for (name, c) in [("P", "p"), ("R", "r"), ("O", "o"), ("B", "b"), ("E", "e")] {
|
||||
type_key(&mut s, name, c);
|
||||
}
|
||||
assert_eq!(text(&s, "AccountLoginAccountEdit"), "probe");
|
||||
|
||||
// Tab moves to the password box (`AccountLoginAccountEdit`'s `OnTabPressed`), which then types.
|
||||
assert!(s.key_input("TAB"));
|
||||
assert_eq!(
|
||||
s.focused_editbox_name().as_deref(),
|
||||
Some("AccountLoginPasswordEdit")
|
||||
);
|
||||
for (name, c) in [("S", "s"), ("E", "e"), ("C", "c")] {
|
||||
type_key(&mut s, name, c);
|
||||
}
|
||||
assert_eq!(text(&s, "AccountLoginPasswordEdit"), "sec");
|
||||
assert_eq!(text(&s, "AccountLoginAccountEdit"), "probe");
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The login screen's Cinematics button shows `MovieFrame`; no movie plays here, so
|
||||
/// `StartMovie` answers nil and the stock `MovieFrame_PlayMovie` hides the frame, whose `OnHide`
|
||||
/// puts the login screen back and the pointer with it (`MovieFrame.lua:18-23,40-44`).
|
||||
#[test]
|
||||
fn the_cinematics_button_comes_back_to_the_login_screen() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = glue_on(GlueScreen::Login);
|
||||
s.register_cvars([("movieSubtitle", "1"), ("gxResolution", "1024x768")]);
|
||||
s.run("AccountLoginCinematicsButton:Click()").unwrap();
|
||||
s.resolve();
|
||||
let (movie, login, cursor_hidden) = (
|
||||
s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap(),
|
||||
s.eval::<bool>("return AccountLoginUI:IsVisible() == 1")
|
||||
.unwrap(),
|
||||
s.glue_cursor_hidden(),
|
||||
);
|
||||
assert_eq!(
|
||||
(movie, login, cursor_hidden),
|
||||
(false, true, false),
|
||||
"the movie frame is gone, the login boxes are back and the pointer shows"
|
||||
);
|
||||
assert_eq!(s.take_errors(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// A frame whose only key script is `OnKeyUp` takes the key-down and runs its `OnKeyUp` on the
|
||||
/// release (`0x76b7d0`, `0x765fd0`, `0x76bba0`): Escape on a playing movie hides the frame
|
||||
/// (`MovieFrame_OnKeyUp`), which returns to login.
|
||||
#[test]
|
||||
fn escape_on_a_playing_movie_returns_to_login_on_the_key_up() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = glue_on(GlueScreen::Login);
|
||||
s.register_cvars([("movieSubtitle", "0"), ("gxResolution", "1024x768")]);
|
||||
// A movie that starts, as the reference's player would.
|
||||
s.run("MovieFrame.StartMovie = function() return 1 end SetGlueScreen('movie')")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap());
|
||||
assert!(s.glue_cursor_hidden(), "a playing movie hides the pointer");
|
||||
|
||||
assert!(
|
||||
s.frame_key_input("SPACE"),
|
||||
"the movie swallows the key-down"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap(),
|
||||
"…and nothing happens until the key is up"
|
||||
);
|
||||
assert!(s.key_input("ESCAPE"));
|
||||
assert!(s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap());
|
||||
s.key_up("ESCAPE");
|
||||
s.resolve();
|
||||
assert!(!s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return AccountLoginUI:IsVisible() == 1")
|
||||
.unwrap());
|
||||
assert!(!s.glue_cursor_hidden());
|
||||
assert_eq!(s.take_errors(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// The Realmlist dialog widens the stock edit box, whose border is two 75-pixel caps that meet at
|
||||
/// its own 130 pixels: while it is wide the stretched middle fills the gap between the caps, as the
|
||||
/// chat box's does (`ChatFrame.xml:41-55`), and the stock box comes back untouched.
|
||||
#[test]
|
||||
fn the_widened_realmlist_box_has_a_border_between_its_caps() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = glue_on(GlueScreen::Login);
|
||||
s.run("BENILLA_REALMLIST = 'logon.example.org' BenillaRealmlistButton:Click()")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let span = |s: &UiScript| {
|
||||
s.eval::<(f64, f64, f64, f64)>(
|
||||
"return GlueDialogEditBox:GetLeft(), GlueDialogEditBox:GetRight(), \
|
||||
BenillaRealmlist_Middle:GetLeft(), BenillaRealmlist_Middle:GetRight()",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let (box_left, box_right, mid_left, mid_right) = span(&s);
|
||||
assert_eq!(box_right - box_left, 300.0);
|
||||
assert_eq!(
|
||||
(mid_left - box_left, box_right - mid_right),
|
||||
(65.0, 65.0),
|
||||
"the middle starts where the left cap ends and ends where the right one starts"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return BenillaRealmlist_Middle:IsShown() == 1")
|
||||
.unwrap());
|
||||
|
||||
s.run("GlueDialogButton2:Click()").unwrap();
|
||||
s.resolve();
|
||||
assert!(!s
|
||||
.eval::<bool>("return BenillaRealmlist_Middle:IsShown() == 1")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GlueDialogEditBox:GetWidth()")
|
||||
.unwrap(),
|
||||
130.0
|
||||
);
|
||||
assert_eq!(s.take_errors(), Vec::<String>::new());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
//! The stock glue screens' 3-D scenes: the glue VM's shown `ModelFFX` pane drives the glue booth
|
||||
//! ([`crate::portrait::GluePreview`]), which renders the scene, its light and fog, the selected
|
||||
//! character on attachment 0 and its pet on attachment 1; the pane then draws the booth's image at
|
||||
//! its own bucket (the extract's `ModelPane` arm), so every GlueXML region paints over it.
|
||||
//!
|
||||
//! The scene is the pane's file: `AccountLogin.xml`'s `UI_MainMenu`, `CharacterSelect`'s OnLoad
|
||||
//! `UI_Orc` (`CharacterSelect.lua:26`), or the stage `SetCharSelectBackground` gives it. Its fog is
|
||||
//! the pane's own until `SetCharSelectBackground` swaps the fog fill callback for the select
|
||||
//! screen's (`0x472110`), which never fogs. The character is the select record the last
|
||||
//! `SelectCharacter` built and attached (`0x472950`), turned by the facing cell (`[0xb4217c]`,
|
||||
//! `SetCharacterSelectFacing`).
|
||||
//!
|
||||
//! The scene fills the pane at the pane's own aspect, as the reference frames it, and starts over
|
||||
//! with each `SetModel` instance; the pane's showing resets the glue glow (`0x46fa60`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{GlueScenePane, UiScript, VmKind};
|
||||
|
||||
use super::glue_mgr::GlueMgr;
|
||||
use crate::portrait::{GlueLook, GluePreview, GlueScene, SceneFog, SelectLook};
|
||||
|
||||
/// What the booth stands while the stock glue screens are up: `pane` is the shown `ModelFFX`'s
|
||||
/// scene, `standing` the character on its attachment 0, `facing` the select facing cell in
|
||||
/// radians. A file that names none of the shipped scenes stands nothing.
|
||||
fn stock_preview(
|
||||
pane: Option<&GlueScenePane>,
|
||||
standing: Option<&benilla_protocol::Character>,
|
||||
facing: f32,
|
||||
) -> (Option<GlueScene>, Option<GlueLook>, f32, Option<SceneFog>) {
|
||||
let Some(pane) = pane else {
|
||||
return (None, None, 0.0, None);
|
||||
};
|
||||
let scene = GlueScene::from_model_path(&pane.path);
|
||||
if scene.is_none() {
|
||||
warn_once!(
|
||||
"glue scene: {} is none of the shipped glue scenes; the glue booth stands nothing",
|
||||
pane.path
|
||||
);
|
||||
}
|
||||
// The character and the facing are the select frame's scene's alone (`[0xb42174]`).
|
||||
let (look, yaw) = if pane.select {
|
||||
(
|
||||
standing.map(|c| GlueLook::Select(SelectLook::from(c))),
|
||||
facing,
|
||||
)
|
||||
} else {
|
||||
(None, 0.0)
|
||||
};
|
||||
let fog = pane.fog.map(|f| SceneFog {
|
||||
rgb: f.rgb(),
|
||||
near: f.near,
|
||||
far: f.far,
|
||||
});
|
||||
(scene, look, yaw, fog)
|
||||
}
|
||||
|
||||
/// What the feeder remembers about the glue VM between frames: the pane shown last and the
|
||||
/// instance it held, numbered into [`GluePreview::stage`].
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StockFeed {
|
||||
shown: Option<benilla_ui::widget::FrameHandle>,
|
||||
instance: Option<(benilla_ui::widget::FrameHandle, u64)>,
|
||||
}
|
||||
|
||||
/// Feed the glue booth from the glue VM while the stock glue screens are up, and stand it down
|
||||
/// once they are not (the world entered, the VM gone).
|
||||
pub(crate) fn feed_stock_glue_preview(
|
||||
script: Option<NonSend<UiScript>>,
|
||||
mgr: Res<GlueMgr>,
|
||||
state: Option<Res<State<crate::char_select::ClientState>>>,
|
||||
mut preview: ResMut<GluePreview>,
|
||||
mut feed: Local<super::VmMemo<StockFeed>>,
|
||||
) {
|
||||
let in_world = state.is_some_and(|s| *s.get() == crate::char_select::ClientState::InWorld);
|
||||
let script = script.filter(|s| s.vm_kind() == VmKind::Glue && !in_world);
|
||||
let pane = script.as_deref().and_then(|s| s.glue_scene());
|
||||
let (scene, look, yaw, fog) = match script.as_deref() {
|
||||
Some(s) => {
|
||||
let standing = s.glue_standing_guid().and_then(|g| mgr.roster_character(g));
|
||||
stock_preview(pane.as_ref(), standing, s.glue_select_cells().facing)
|
||||
}
|
||||
None => (None, None, 0.0, None),
|
||||
};
|
||||
// A new VM's panes are new frames: the memo starts over with it.
|
||||
let feed = feed.get_for(script.as_deref());
|
||||
// A pane that was not the one shown last frame has just shown: its OnShow (`0x46fa60`).
|
||||
let shown = pane.as_ref().map(|p| p.handle);
|
||||
if shown.is_some() && shown != feed.shown {
|
||||
preview.shown = preview.shown.wrapping_add(1);
|
||||
}
|
||||
feed.shown = shown;
|
||||
// A new instance, or another pane's, is another scene.
|
||||
let instance = pane.as_ref().map(|p| (p.handle, p.instance));
|
||||
if instance.is_some() && instance != feed.instance {
|
||||
feed.instance = instance;
|
||||
preview.stage = preview.stage.wrapping_add(1);
|
||||
}
|
||||
let rect = pane.as_ref().and_then(|p| p.rect);
|
||||
if preview.pane != rect {
|
||||
preview.pane = rect;
|
||||
}
|
||||
if preview.scene != scene {
|
||||
preview.scene = scene;
|
||||
}
|
||||
if preview.look != look {
|
||||
preview.look = look;
|
||||
}
|
||||
if preview.yaw != yaw {
|
||||
preview.yaw = yaw;
|
||||
}
|
||||
if preview.fog != fog {
|
||||
preview.fog = fog;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A shown pane's scene as a glue VM reads it, then given `path`, `select` and `fog`.
|
||||
fn pane(path: &str, select: bool, fog: Option<benilla_ui::widget::ModelFog>) -> GlueScenePane {
|
||||
let s = UiScript::new_glue().unwrap();
|
||||
s.run("CreateFrame('ModelFFX', 'Pane'):SetModel('x.mdx')")
|
||||
.unwrap();
|
||||
GlueScenePane {
|
||||
path: path.into(),
|
||||
fog,
|
||||
select,
|
||||
..s.glue_scene().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// The login's pane: `UI_MainMenu` under `AccountLogin.xml`'s fog, nobody on it.
|
||||
#[test]
|
||||
fn the_main_menu_pane_stands_its_scene_under_its_own_fog() {
|
||||
let fog = benilla_ui::widget::ModelFog {
|
||||
color: 0xff40_0f04,
|
||||
near: 0.0,
|
||||
far: 1200.0,
|
||||
};
|
||||
let c = crate::char_select::test_character(7, "Seven");
|
||||
let (scene, look, yaw, got) = stock_preview(
|
||||
Some(&pane(
|
||||
"Interface\\Glues\\Models\\UI_MainMenu\\UI_MainMenu.mdx",
|
||||
false,
|
||||
Some(fog),
|
||||
)),
|
||||
Some(&c),
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(scene, Some(GlueScene::MainMenu));
|
||||
assert!(
|
||||
look.is_none() && yaw == 0.0,
|
||||
"only the select frame's scene holds a character"
|
||||
);
|
||||
let got = got.expect("fogged");
|
||||
assert_eq!((got.near, got.far), (0.0, 1200.0));
|
||||
assert_eq!(got.rgb, fog.rgb());
|
||||
}
|
||||
|
||||
/// The stock login's pane is `GlueParent`'s rect, which fills the screen up to 16:9 and is a
|
||||
/// centred 16:9 box past it (`GlueParent.lua:78-84`): the rect the scene is framed in.
|
||||
#[test]
|
||||
fn the_stock_pane_is_glue_parents_rect_at_every_aspect() {
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
for aspect in [4.0 / 3.0, 16.0 / 10.0, 16.0 / 9.0, 21.0 / 9.0] {
|
||||
let mut s = UiScript::new_glue().unwrap();
|
||||
s.set_screen_size(768.0 * aspect, 768.0);
|
||||
let _ = super::super::glue::load_glue(&mut s);
|
||||
super::super::glue::set_glue_screen(&mut s, super::super::glue::GlueScreen::Login);
|
||||
s.resolve();
|
||||
let pane = s.glue_scene().expect("AccountLogin is shown");
|
||||
assert!(pane.path.ends_with("UI_MainMenu.mdx"), "{}", pane.path);
|
||||
let bar = ((aspect - 16.0 / 9.0) / (2.0 * aspect)).max(0.0);
|
||||
let rect = pane.rect.expect("laid out");
|
||||
for (got, want) in rect.iter().zip([bar, 1.0 - bar, 0.0, 1.0]) {
|
||||
assert!((got - want).abs() < 1e-4, "{aspect}: {rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_shown_pane_and_an_unknown_file_stand_nothing() {
|
||||
assert_eq!(stock_preview(None, None, 0.0).0, None);
|
||||
assert_eq!(
|
||||
stock_preview(
|
||||
Some(&pane("Interface\\Glues\\Models\\Odd.mdx", true, None)),
|
||||
None,
|
||||
0.0
|
||||
)
|
||||
.0,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,6 @@ fn rolls() -> LootRollsState {
|
||||
roll_id: 7,
|
||||
name: Some("Staff of Jordan".into()),
|
||||
texture: Some("Interface\\Icons\\INV_Staff_12".into()),
|
||||
quantity: 1,
|
||||
quality: Some(4), // Epic -> purple
|
||||
bind_on_pickup: true,
|
||||
time_left_ms: 42_000,
|
||||
@@ -111,7 +110,6 @@ fn rolls() -> LootRollsState {
|
||||
roll_id: 8,
|
||||
name: Some("Worn Shortsword".into()),
|
||||
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
||||
quantity: 1,
|
||||
quality: Some(1), // Common -> white
|
||||
bind_on_pickup: false,
|
||||
time_left_ms: 60_000,
|
||||
@@ -124,7 +122,6 @@ fn rolls() -> LootRollsState {
|
||||
roll_id: 9,
|
||||
name: None,
|
||||
texture: None,
|
||||
quantity: 1,
|
||||
quality: None,
|
||||
bind_on_pickup: false,
|
||||
time_left_ms: 55_000,
|
||||
|
||||
@@ -7,9 +7,12 @@ use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::script::{KeyWalk, UiScript};
|
||||
|
||||
use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
|
||||
use super::{
|
||||
CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, PlayerUiPressConsumed,
|
||||
UiKeyboardCapture,
|
||||
};
|
||||
use crate::bindings::WheelNotches;
|
||||
use crate::textinput::{self, keymap, HostClipboard};
|
||||
|
||||
@@ -20,6 +23,7 @@ use crate::textinput::{self, keymap, HostClipboard};
|
||||
pub(super) struct PointerFeed<'w> {
|
||||
hover: ResMut<'w, PlayerUiHover>,
|
||||
click_consumed: ResMut<'w, PlayerUiClickConsumed>,
|
||||
press_consumed: ResMut<'w, PlayerUiPressConsumed>,
|
||||
hovered: Res<'w, crate::target::Hovered>,
|
||||
hovered_object: Res<'w, crate::target::HoveredObject>,
|
||||
occlusion: Res<'w, crate::target::PickOcclusion>,
|
||||
@@ -92,21 +96,32 @@ pub(super) fn feed_ui_input(
|
||||
ResMut<UiKeyboardCapture>,
|
||||
NonSendMut<HostClipboard>,
|
||||
),
|
||||
// The keys this feed believes down, a fact about the keyboard and not the VM.
|
||||
mut pressed: Local<crate::bindings::KeyDownList>,
|
||||
// Who sends the key-ups: the bindings pass in the world, this feed on the glue screens.
|
||||
state: Option<Res<State<crate::char_select::ClientState>>>,
|
||||
// The characters the active layout makes, which name a key for a keyboard frame.
|
||||
layout: Res<crate::bindings::LayoutNames>,
|
||||
) {
|
||||
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
|
||||
// In the world the bindings pass sends the key-ups, in event order with the presses it
|
||||
// follows; on the glue screens, which have none, this feed does.
|
||||
let releases = !state
|
||||
.as_ref()
|
||||
.is_some_and(|s| *s.get() == crate::char_select::ClientState::InWorld);
|
||||
let world_pick = pointer.world_pick();
|
||||
// The OS pointer is not ours while a probe drives a gesture through the real pointer path or a
|
||||
// capture pins it: skip the mouse half whole, else-arm included, whose `pointer_left_window`
|
||||
// would disarm the probe's gesture between its press and release.
|
||||
let synthetic = pointer.synthetic.0 || pointer.capture_pinned.0;
|
||||
let (hover, click_consumed, payload_held) = (
|
||||
let (hover, click_consumed, press_consumed, payload_held) = (
|
||||
&mut pointer.hover,
|
||||
&mut pointer.click_consumed,
|
||||
&mut pointer.press_consumed,
|
||||
&mut pointer.payload_held,
|
||||
);
|
||||
click_consumed.0 = false;
|
||||
press_consumed.0 = false;
|
||||
let Some(mut script) = script else {
|
||||
capture.typing = false;
|
||||
capture.arrows_fall_through = false;
|
||||
@@ -183,7 +198,8 @@ pub(super) fn feed_ui_input(
|
||||
click_consumed.0 = true;
|
||||
}
|
||||
}
|
||||
script.mouse_button(x, y, name, true);
|
||||
// A press the UI consumed is not the world's, hovered frame or not.
|
||||
press_consumed.0 |= script.mouse_button(x, y, name, true);
|
||||
}
|
||||
if buttons.just_released(btn) {
|
||||
script.mouse_button(x, y, name, false);
|
||||
@@ -194,7 +210,7 @@ pub(super) fn feed_ui_input(
|
||||
// The OS pointer left: leave the hovered frame once, and disarm any press or drag every
|
||||
// frame, since no release ends it and a stale gesture would fire on re-entry.
|
||||
if hover.0.take().is_some() {
|
||||
script.mouse_move(f32::MIN, f32::MIN);
|
||||
script.pointer_left_hover();
|
||||
}
|
||||
script.pointer_left_window();
|
||||
}
|
||||
@@ -203,15 +219,20 @@ pub(super) fn feed_ui_input(
|
||||
// and before the keys (an Escape that clears focus still counts as captured this frame).
|
||||
capture.typing = script.has_keyboard_focus();
|
||||
capture.consumed.clear();
|
||||
script.set_alt_down(alt);
|
||||
// ── The alt-arrow exemption ────────────────────────────────────────────────────────────────
|
||||
// An EditBox in alt-arrow mode (`ignoreArrows`, set on the chat box at `ChatFrame.xml:21`)
|
||||
// passes the arrows on unless ALT is held: its handler returns 0 at `0x77b1c4` and the
|
||||
// binding runs at `CGWorldFrame`; a focused box swallows any other key (`0x77b35e` returns 1).
|
||||
// binding runs at `CGWorldFrame`; a focused box swallows any other key (`0x77b35e` returns 1),
|
||||
// PRINTSCREEN excepted (`0x77b17f`, see `crate::bindings`).
|
||||
capture.arrows_fall_through = !alt && script.editbox_alt_arrow_mode();
|
||||
|
||||
// ── Keyboard → VM ── box-event keys go to `key_input` by name, editing keys through the
|
||||
// per-OS chord table as an `EditAction` or a clipboard operation (the macOS NSPasteboard
|
||||
// needs this main-thread system), the rest to `char_input`. Repeats arrive as `Pressed`.
|
||||
// ── Keyboard → VM ── every press is one key-down walk by name (`0x765f10`), in which a box is
|
||||
// a frame like the rest; a box the walk reached then takes the key's editing action through
|
||||
// the per-OS chord table as an `EditAction` or a clipboard operation (the macOS NSPasteboard
|
||||
// needs this main-thread system), and the characters go to `char_input`, a walk of its own.
|
||||
// A repeat goes to the frame that took the press instead (`0x766070`), a release likewise
|
||||
// (`0x765fd0`).
|
||||
let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
|
||||
let mods = keymap::Mods {
|
||||
shift,
|
||||
@@ -219,84 +240,121 @@ pub(super) fn feed_ui_input(
|
||||
alt,
|
||||
sup,
|
||||
};
|
||||
feed_keys(
|
||||
&mut script,
|
||||
capture,
|
||||
keyboard.read(),
|
||||
keys,
|
||||
&mut pressed,
|
||||
releases,
|
||||
mods,
|
||||
&layout,
|
||||
clipboard,
|
||||
wl_display,
|
||||
);
|
||||
|
||||
// The payload-held mirror, written last so a same-frame pickup or ESC clear shows: the
|
||||
// Send-side view the world-click deselect gates on.
|
||||
payload_held.0 = script.cursor_payload().is_some();
|
||||
|
||||
for err in script.take_errors() {
|
||||
warn!("ui_script(input): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard half of the frame: each event as the key-down or repeat walk and what a box the
|
||||
/// walk reached does with the key. `down` is this feed's pressed-key list, which decides repeat
|
||||
/// as the bindings' does (`0x4248b3`), never from the OS flag: after an alt-tab wipes it
|
||||
/// (`0x424790`, the reconcile against `keys`) the first repeat is a fresh press, and a platform
|
||||
/// that never sets the flag still repeats right. `releases` says whether the key-up dispatch is
|
||||
/// this feed's: in the world the bindings pass sends it, in order with the press it follows
|
||||
/// (`crate::bindings`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn feed_keys<'a>(
|
||||
script: &mut UiScript,
|
||||
capture: &mut UiKeyboardCapture,
|
||||
events: impl Iterator<Item = &'a KeyboardInput>,
|
||||
keys: &ButtonInput<KeyCode>,
|
||||
down: &mut crate::bindings::KeyDownList,
|
||||
releases: bool,
|
||||
mods: keymap::Mods,
|
||||
layout: &crate::bindings::LayoutNames,
|
||||
clipboard: &mut HostClipboard,
|
||||
wl_display: Option<*mut std::ffi::c_void>,
|
||||
) {
|
||||
down.reconcile(keys);
|
||||
let keymap::Mods {
|
||||
shift: _,
|
||||
ctrl,
|
||||
alt,
|
||||
sup,
|
||||
} = mods;
|
||||
let mac = cfg!(target_os = "macos");
|
||||
for ev in keyboard.read() {
|
||||
for ev in events {
|
||||
// What the layout names the key: the clipboard chords' letters, and a keyboard frame's
|
||||
// `arg1`, which the Key Bindings window also stores, so a key's press dispatches by the
|
||||
// name it was bound under.
|
||||
let token = crate::bindings::chord::key_token(ev.key_code, layout);
|
||||
// `None` is a key the client drops before any walk (a Windows or Command key, a media
|
||||
// key): no down, no up, and no focus granted to an `autoFocus` box.
|
||||
let name = crate::bindings::chord::walk_name(ev.key_code, layout);
|
||||
if ev.state != ButtonState::Pressed {
|
||||
down.release(ev.key_code);
|
||||
if let Some(name) = name.as_deref().filter(|_| releases) {
|
||||
script.key_up(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let named = match ev.key_code {
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => Some("ENTER"),
|
||||
KeyCode::Escape => Some("ESCAPE"),
|
||||
KeyCode::Tab => Some("TAB"),
|
||||
_ => None,
|
||||
let repeat = down.press(ev.key_code);
|
||||
let named = matches!(
|
||||
ev.key_code,
|
||||
KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Escape | KeyCode::Tab
|
||||
);
|
||||
let walk = match name.as_deref() {
|
||||
Some(name) if repeat => script.key_repeat(name),
|
||||
Some(name) => script.key_down(name),
|
||||
None => KeyWalk::Declined,
|
||||
};
|
||||
// What the layout names the key: the clipboard chords' letters, and a keyboard frame's
|
||||
// `arg1` below.
|
||||
let token = crate::bindings::chord::key_token(ev.key_code, &layout);
|
||||
// Dispatched unconditionally: unfocused, they fall through to the camera and turn keys.
|
||||
// The gate is existence, not handling: a shown keyboard frame with an `OnKeyDown` swallows
|
||||
// the key whatever its script does (`0x76b7d0`, `0x76ba25`). Consumption suppresses only
|
||||
// the key's binding: `OnChar` is a separate dispatcher (`0x765df0`), so the stack-split
|
||||
// spinner still gets a digit its `OnKeyDown` ate, and it is not a focus change, so it
|
||||
// releases nothing held. A box that took the key counts too: it returned 1 (`0x77b1db`
|
||||
// -> `0x77b35e`) and the world frame, last in the walk, never saw it, so an F-key that
|
||||
// gave an `autoFocus` box the focus runs no binding. An unconsumed press is acted on in
|
||||
// the binding dispatch (`crate::bindings`), which runs after this pass and reads the
|
||||
// capture gate.
|
||||
if walk != KeyWalk::Declined {
|
||||
capture.consumed.push(ev.key_code);
|
||||
}
|
||||
let editing_key = matches!(
|
||||
ev.key_code,
|
||||
KeyCode::Backspace
|
||||
| KeyCode::Delete
|
||||
| KeyCode::ArrowLeft
|
||||
| KeyCode::ArrowRight
|
||||
| KeyCode::ArrowUp
|
||||
| KeyCode::ArrowDown
|
||||
| KeyCode::Home
|
||||
| KeyCode::End
|
||||
);
|
||||
if walk == KeyWalk::Frame && editing_key {
|
||||
// Not `typing`: a frame eating one key is not a box taking focus, and treating it as
|
||||
// one would stop a held movement key.
|
||||
continue;
|
||||
}
|
||||
if named {
|
||||
continue;
|
||||
}
|
||||
// The box's own key-down switch (`0x77b160`) does the editing, copy, cut, paste and
|
||||
// select-all, so only a box the walk reached acts; a frame that took the key, or a gated
|
||||
// arrow the box declined, leaves it alone.
|
||||
let chord = keymap::chord(ev.key_code, token, mods, mac);
|
||||
// A keyboard frame gets these by name before their chord runs (a dialog needs BACKSPACE);
|
||||
// `frame_key_input` declines at a focused box, so no frame steals its editing keys. `true`
|
||||
// suppresses the chord and the key's binding (the reference's existence gate, `0x76b7d0`).
|
||||
let frame_named = match ev.key_code {
|
||||
KeyCode::Backspace => Some("BACKSPACE"),
|
||||
KeyCode::Delete => Some("DELETE"),
|
||||
KeyCode::ArrowLeft => Some("LEFT"),
|
||||
KeyCode::ArrowRight => Some("RIGHT"),
|
||||
KeyCode::ArrowUp => Some("UP"),
|
||||
KeyCode::ArrowDown => Some("DOWN"),
|
||||
KeyCode::Home => Some("HOME"),
|
||||
KeyCode::End => Some("END"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(name) = frame_named {
|
||||
if script.frame_key_input(name) {
|
||||
// Not `typing`: a frame eating one key is not a box taking focus, and treating it
|
||||
// as one would stop a held movement key.
|
||||
capture.consumed.push(ev.key_code);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Every other key reaches a keyboard frame by name too: the reference's key-down walk
|
||||
// takes its `arg1` from the table the binding chord uses (`0x4b66b0`), `chord::key_token`
|
||||
// under the active layout here, so the Key Bindings window stores the name the key's
|
||||
// press dispatches by. The gate is existence, not handling: a shown keyboard frame with
|
||||
// an `OnKeyDown` swallows the key whatever its script does (`0x76b7d0`, `0x76ba25`).
|
||||
// Consumption suppresses only the key's binding: `OnChar` is a separate dispatcher
|
||||
// (`0x765df0`), so the stack-split spinner still gets a digit its `OnKeyDown` ate, and it
|
||||
// is not a focus change, so it releases nothing held.
|
||||
else if named.is_none() {
|
||||
if let Some(token) = token {
|
||||
if script.frame_key_input(&token.to_string()) {
|
||||
capture.consumed.push(ev.key_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(name) = named {
|
||||
// An unconsumed press is not acted on here: ESCAPE's close ladder, TAB's targeting and
|
||||
// ENTER's chat open live in the binding dispatch (`crate::bindings`), which runs after
|
||||
// this pass and reads the capture gate.
|
||||
if script.key_input(name) {
|
||||
capture.consumed.push(ev.key_code);
|
||||
}
|
||||
} else if let Some(chord) = chord {
|
||||
// The gate is on the key, not the action: a gated arrow never reaches the box (the
|
||||
// reference's `return 0`), while HOME/END, which also make a `Move`, do.
|
||||
let gated_arrow = capture.arrows_fall_through
|
||||
&& matches!(
|
||||
ev.key_code,
|
||||
KeyCode::ArrowLeft
|
||||
| KeyCode::ArrowRight
|
||||
| KeyCode::ArrowUp
|
||||
| KeyCode::ArrowDown
|
||||
);
|
||||
if let Some(chord) = chord.filter(|_| walk == KeyWalk::Box) {
|
||||
match chord {
|
||||
keymap::Chord::Edit(action) if !gated_arrow => {
|
||||
keymap::Chord::Edit(action) => {
|
||||
script.editbox_action(action);
|
||||
}
|
||||
// Declined: the key falls through to its binding, which `bindings.rs` allows
|
||||
// because `arrows_fall_through` exempts exactly these four from the typing gate.
|
||||
keymap::Chord::Edit(_) => {}
|
||||
// Copy and cut need a selection (`0x77e1d0`), and a password box yields its mask
|
||||
// run, never the real text; paste sanitizes and inserts.
|
||||
keymap::Chord::Copy => {
|
||||
@@ -315,26 +373,16 @@ pub(super) fn feed_ui_input(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !(sup || (ctrl && !alt)) {
|
||||
} else if chord.is_none() && !(sup || (ctrl && !alt)) {
|
||||
// Command-modified chars never insert (Cmd+L must not type "l"), but Ctrl+Alt passes:
|
||||
// that is AltGr, and macOS Option+letter arrives with only `alt`.
|
||||
// The char walk gates no binding (`0x765df0` reaches no keybinding), so whether a
|
||||
// frame took the character is not recorded: the key-down already decided that.
|
||||
if let Some(text) = &ev.text {
|
||||
// Same suppression as the named keys: the number row is a binding, so a digit typed
|
||||
// into a keyboard frame (the stack-split spinner) must not also fire an action.
|
||||
if script.char_input(text) {
|
||||
capture.consumed.push(ev.key_code);
|
||||
}
|
||||
script.char_input(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The payload-held mirror, written last so a same-frame pickup or ESC clear shows: the
|
||||
// Send-side view the world-click deselect gates on.
|
||||
payload_held.0 = script.cursor_payload().is_some();
|
||||
|
||||
for err in script.take_errors() {
|
||||
warn!("ui_script(input): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! The host's keyboard feed ([`super::input::feed_keys`]) against the reference's four dispatchers:
|
||||
//! key-down (`0x765f10`), char (`0x765df0`), key-up (`0x765fd0`) and auto-repeat (`0x766070`).
|
||||
|
||||
use bevy::input::keyboard::{Key, KeyboardInput};
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::bag_tests::open_backpack_with_a_five_stack;
|
||||
use super::input::feed_keys;
|
||||
use super::UiKeyboardCapture;
|
||||
use crate::bindings::{KeyDownList, LayoutNames};
|
||||
use crate::textinput::{keymap::Mods, HostClipboard};
|
||||
|
||||
/// One keyboard event as winit hands it over.
|
||||
fn ev(code: KeyCode, state: ButtonState, repeat: bool, text: Option<&str>) -> KeyboardInput {
|
||||
KeyboardInput {
|
||||
key_code: code,
|
||||
logical_key: Key::Character(text.unwrap_or("?").into()),
|
||||
state,
|
||||
text: text.map(Into::into),
|
||||
repeat,
|
||||
window: Entity::PLACEHOLDER,
|
||||
}
|
||||
}
|
||||
|
||||
fn down(code: KeyCode, text: Option<&str>) -> KeyboardInput {
|
||||
ev(code, ButtonState::Pressed, false, text)
|
||||
}
|
||||
|
||||
fn repeat(code: KeyCode) -> KeyboardInput {
|
||||
ev(code, ButtonState::Pressed, true, None)
|
||||
}
|
||||
|
||||
fn up(code: KeyCode) -> KeyboardInput {
|
||||
ev(code, ButtonState::Released, false, None)
|
||||
}
|
||||
|
||||
/// A harness for one script: the capture the bindings read, a clipboard holding `paste`.
|
||||
struct Feed {
|
||||
capture: UiKeyboardCapture,
|
||||
clipboard: HostClipboard,
|
||||
mods: Mods,
|
||||
keys: ButtonInput<KeyCode>,
|
||||
down: KeyDownList,
|
||||
}
|
||||
|
||||
impl Feed {
|
||||
fn new(paste: &str) -> Self {
|
||||
Feed {
|
||||
capture: UiKeyboardCapture::default(),
|
||||
clipboard: HostClipboard::holding(paste),
|
||||
mods: Mods {
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
sup: false,
|
||||
},
|
||||
keys: ButtonInput::default(),
|
||||
down: KeyDownList::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The events as the frame brings them, `ButtonInput` following them as bevy's does.
|
||||
fn send(&mut self, script: &mut UiScript, events: &[KeyboardInput]) {
|
||||
script.set_alt_down(self.mods.alt);
|
||||
self.keys.clear();
|
||||
for ev in events {
|
||||
match ev.state {
|
||||
ButtonState::Pressed => self.keys.press(ev.key_code),
|
||||
ButtonState::Released => self.keys.release(ev.key_code),
|
||||
}
|
||||
}
|
||||
feed_keys(
|
||||
script,
|
||||
&mut self.capture,
|
||||
events.iter(),
|
||||
&self.keys,
|
||||
&mut self.down,
|
||||
true,
|
||||
self.mods,
|
||||
&LayoutNames::default(),
|
||||
&mut self.clipboard,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat-like box in alt-arrow mode at DIALOG, focused, over the real split dialog: the arrows
|
||||
/// are the box's to decline (`0x77b18e`-`0x77b1c4`), so the dialog steps with them, and holding
|
||||
/// RIGHT steps it once (`0x766070` hands the repeat to the dialog's stub).
|
||||
#[test]
|
||||
fn the_split_dialog_steps_with_the_arrows_under_a_focused_chat_box() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let (x, y) = open_backpack_with_a_five_stack(&mut s);
|
||||
s.set_modifiers(true, false, false);
|
||||
s.mouse_button(x, y, "LeftButton", true);
|
||||
s.mouse_button(x, y, "LeftButton", false);
|
||||
s.set_modifiers(false, false, false);
|
||||
s.run(
|
||||
r#"
|
||||
ChatLike = CreateFrame("EditBox", "ChatLike")
|
||||
ChatLike:SetAutoFocus(false)
|
||||
ChatLike:SetAltArrowKeyMode(1)
|
||||
ChatLike:SetFrameStrata("DIALOG")
|
||||
ChatLike:SetFrameLevel(StackSplitFrame:GetFrameLevel() + 1)
|
||||
ChatLike:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 1);
|
||||
|
||||
let mut feed = Feed::new("");
|
||||
feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
|
||||
2,
|
||||
"the dialog took the arrow the box declined"
|
||||
);
|
||||
assert!(feed.capture.consumed.contains(&KeyCode::ArrowRight));
|
||||
feed.send(
|
||||
&mut s,
|
||||
&[repeat(KeyCode::ArrowRight), repeat(KeyCode::ArrowRight)],
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
|
||||
2,
|
||||
"holding RIGHT steps once"
|
||||
);
|
||||
feed.send(&mut s, &[up(KeyCode::ArrowRight)]);
|
||||
feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]);
|
||||
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 3);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return ChatLike:GetText()").unwrap(),
|
||||
"",
|
||||
"and the box saw nothing"
|
||||
);
|
||||
|
||||
// With ALT the box keeps its arrows.
|
||||
feed.mods.alt = true;
|
||||
feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]);
|
||||
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 3);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The paste chord with a keyboard frame above a focused box: the frame took the key-down, and the paste
|
||||
/// is the box's own key-down arm (`0x77b160`), which the walk never reached.
|
||||
#[test]
|
||||
fn a_frame_that_took_the_key_keeps_the_paste_from_the_box() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
got = {}
|
||||
box = CreateFrame("EditBox", "PasteBox")
|
||||
box:SetAutoFocus(false)
|
||||
box:SetFrameStrata("DIALOG")
|
||||
box:SetFocus()
|
||||
map = CreateFrame("Frame", "PasteMap")
|
||||
map:SetFrameStrata("FULLSCREEN_DIALOG")
|
||||
map:EnableKeyboard(true)
|
||||
map:SetScript("OnKeyDown", function() table.insert(got, arg1) end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut feed = Feed::new("pasted");
|
||||
// The paste chord of the platform: Cmd+V on a Mac, Ctrl+V elsewhere.
|
||||
feed.mods.ctrl = !cfg!(target_os = "macos");
|
||||
feed.mods.sup = cfg!(target_os = "macos");
|
||||
feed.send(&mut s, &[down(KeyCode::KeyV, Some("\u{16}"))]);
|
||||
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(got, ',')").unwrap(),
|
||||
"V"
|
||||
);
|
||||
|
||||
// The map closes: the walk reaches the box, which pastes.
|
||||
s.run("map:Hide()").unwrap();
|
||||
feed.send(&mut s, &[up(KeyCode::KeyV)]);
|
||||
feed.send(&mut s, &[down(KeyCode::KeyV, Some("\u{16}"))]);
|
||||
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "pasted");
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The modifiers go through the walk as key-downs named `SHIFT`, `CTRL` and `ALT` (`0x76b7d0`'s
|
||||
/// table), the codes it cannot name (ScrollLock) as `UNKNOWN`, each with its key-up; a key the
|
||||
/// client drops (Windows, Menu, fn, Command) walks as nothing.
|
||||
#[test]
|
||||
fn modifiers_and_unnamed_keys_walk_as_keys_with_their_ups() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
f = CreateFrame("Frame", "ModWatch")
|
||||
f:EnableKeyboard(true)
|
||||
f:SetScript("OnKeyDown", function() table.insert(log, "v" .. arg1) end)
|
||||
f:SetScript("OnKeyUp", function() table.insert(log, "^" .. arg1) end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut feed = Feed::new("");
|
||||
for code in [
|
||||
KeyCode::ShiftLeft,
|
||||
KeyCode::ControlRight,
|
||||
KeyCode::AltLeft,
|
||||
KeyCode::ScrollLock,
|
||||
// The client drops these before any walk (`0x42da39`; the Mac table's -1): no down, no up.
|
||||
KeyCode::Fn,
|
||||
KeyCode::SuperLeft,
|
||||
KeyCode::ContextMenu,
|
||||
] {
|
||||
feed.send(&mut s, &[down(code, None), up(code)]);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
"vSHIFT,^SHIFT,vCTRL,^CTRL,vALT,^ALT,vUNKNOWN,^UNKNOWN"
|
||||
);
|
||||
assert!(feed.capture.consumed.contains(&KeyCode::ShiftLeft));
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A box the walk reached ate the key (`0x77b1db` -> `0x77b35e`; the world frame never sees it), so
|
||||
/// an F-key that gave an `autoFocus` box the focus runs no binding; the character walk gates
|
||||
/// nothing, so a frame that took a character leaves its key's binding alone.
|
||||
#[test]
|
||||
fn a_box_that_took_the_key_eats_its_binding_and_a_char_gates_none() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
box = CreateFrame("EditBox", "EatBox")
|
||||
box:SetAutoFocus(true)
|
||||
box:Hide() box:Show()
|
||||
box:ClearFocus()
|
||||
chars = CreateFrame("Frame", "CharsOnly")
|
||||
chars:SetFrameStrata("FULLSCREEN_DIALOG")
|
||||
chars:EnableKeyboard(true)
|
||||
chars:SetScript("OnChar", function() end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut feed = Feed::new("");
|
||||
feed.send(&mut s, &[down(KeyCode::F5, None)]);
|
||||
assert_eq!(s.focused_editbox_name().as_deref(), Some("EatBox"));
|
||||
assert!(
|
||||
feed.capture.consumed.contains(&KeyCode::F5),
|
||||
"the box ate F5, so its binding does not run"
|
||||
);
|
||||
|
||||
s.run("box:ClearFocus() box:Hide()").unwrap();
|
||||
feed.send(&mut s, &[up(KeyCode::F5)]);
|
||||
feed.capture.consumed.clear();
|
||||
feed.send(&mut s, &[down(KeyCode::KeyQ, Some("q"))]);
|
||||
assert!(
|
||||
feed.capture.consumed.is_empty(),
|
||||
"OnChar took the character, which is no reason to eat Q's binding"
|
||||
);
|
||||
}
|
||||
|
||||
/// A key the client drops before any walk (Windows key, Command, fn) gives an `autoFocus` box no
|
||||
/// focus and no key-up; the Windows key is `SuperLeft` here.
|
||||
#[test]
|
||||
fn a_dropped_key_walks_nowhere_and_grants_no_focus() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
box = CreateFrame("EditBox", "DropBox")
|
||||
box:SetAutoFocus(true)
|
||||
box:Hide() box:Show()
|
||||
box:ClearFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut feed = Feed::new("");
|
||||
feed.send(
|
||||
&mut s,
|
||||
&[down(KeyCode::SuperLeft, None), up(KeyCode::SuperLeft)],
|
||||
);
|
||||
assert_eq!(
|
||||
s.focused_editbox_name(),
|
||||
None,
|
||||
"Cmd+Tab gives no box the focus"
|
||||
);
|
||||
feed.send(&mut s, &[down(KeyCode::KeyA, Some("a"))]);
|
||||
assert_eq!(s.focused_editbox_name().as_deref(), Some("DropBox"));
|
||||
}
|
||||
@@ -675,9 +675,10 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
// Only the list's bar shows in the shared gutter. The outer page's range is not 0, as the
|
||||
// reference would measure it: the stock faux list sizes its scroll child to
|
||||
// `numItems * valueStep`, and the range unions the child's whole subtree (`0x786f80`).
|
||||
// Only the list's bar shows in the shared gutter. The outer page's engine range is not 0, as
|
||||
// the reference would measure it: the stock faux list sizes its scroll child to
|
||||
// `numItems * valueStep`, and the range unions the child's whole subtree (`0x786f80`). The
|
||||
// layer's bar follows the body's own overhang, which is 0.
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaOptionsFrameContainerScrollBar:IsVisible()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,38 +1,137 @@
|
||||
//! The UI session's lifecycle, all edges and nothing per-frame: `Startup` installs a boot VM
|
||||
//! (strings and emote tokens); world entry builds a fresh VM and loads the in-game UI and every
|
||||
//! addon onto it; leaving the world runs the reference's shutdown tail and installs a boot
|
||||
//! VM again; `ReloadUI()` is the two edges back to back without leaving the world.
|
||||
//! (strings and emote tokens), or the stock glue screens' VM under `WOW_GLUE_STOCK=1`; world entry
|
||||
//! builds a fresh VM and loads the in-game UI and every addon onto it; leaving the world runs the
|
||||
//! reference's shutdown tail and installs the glue-side VM again; `ReloadUI()` is the two edges
|
||||
//! back to back without leaving the world.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::script::{GlueNotice, UiScript};
|
||||
|
||||
use super::manifest::silenced_ui_load;
|
||||
use super::{load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock, UiKeyboardCapture};
|
||||
use crate::ui_script::addons;
|
||||
|
||||
pub(crate) fn setup_script(world: &mut World) {
|
||||
install_boot_vm(world);
|
||||
install_glue_side_vm(world, super::glue::GlueScreen::Login);
|
||||
}
|
||||
|
||||
/// Install the VM the world holds outside the world: the stock glue screens' under
|
||||
/// [`super::glue::GlueStock`], put up on `screen`, else a boot VM.
|
||||
///
|
||||
/// The IO thread's roster policy follows the VM that is up: the screen asks for its roster only
|
||||
/// when the stock glue VM was built and its character select screen loaded, so a run whose stock VM failed to build (logged as an error
|
||||
/// by [`new_vm`]) falls back to the park's own enumeration instead of waiting on a screen that
|
||||
/// never asks.
|
||||
fn install_glue_side_vm(world: &mut World, screen: super::glue::GlueScreen) {
|
||||
let stock_asked = world
|
||||
.get_resource::<super::glue::GlueStock>()
|
||||
.is_some_and(|s| s.0);
|
||||
let stock_up = if stock_asked {
|
||||
install_glue_vm(world, screen)
|
||||
} else {
|
||||
install_boot_vm(world);
|
||||
false
|
||||
};
|
||||
if stock_asked && !stock_up {
|
||||
error!("ui_script: WOW_GLUE_STOCK=1 but the stock glue VM did not come up with its character select screen; the roster is fetched at the park");
|
||||
}
|
||||
if let Some(policy) = world.get_resource::<crate::net::RosterPolicy>() {
|
||||
policy
|
||||
.0
|
||||
.store(stock_up, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and install a boot VM, the one the world holds outside an in-game UI session: the
|
||||
/// strings the feeds resolve lines from and the emote tokens `/`-commands are built from
|
||||
/// (`ui_chat::commands`), no frames and no font objects. Installed at `Startup` and at every
|
||||
/// [`end_ui_session`].
|
||||
/// [`end_ui_session`], unless the stock glue screens stand there instead ([`install_glue_vm`]).
|
||||
fn install_boot_vm(world: &mut World) {
|
||||
let Some(script) = new_vm(world) else {
|
||||
return;
|
||||
};
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
world.insert_non_send_resource(script);
|
||||
if let Some(script) = strings_vm(world) {
|
||||
world.insert_non_send_resource(script);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh VM on the process clock with the addon asset probes wired, or `None` (the world's VM
|
||||
/// removed) when the VM cannot start.
|
||||
fn new_vm(world: &mut World) -> Option<UiScript> {
|
||||
let mut script = match UiScript::new() {
|
||||
/// A boot VM's contents on a VM of their own: `GlobalStrings.lua` and the emote tokens, for a
|
||||
/// reader that needs them where the world holds the glue VM. `None` (the world's VM removed) when
|
||||
/// the VM cannot start.
|
||||
pub(crate) fn strings_vm(world: &mut World) -> Option<UiScript> {
|
||||
let script = new_vm(world, UiScript::new)?;
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
Some(script)
|
||||
}
|
||||
|
||||
/// Build the glue VM, load the stock GlueXML onto it, install it and put `screen` up: the glue
|
||||
/// builder `0x46a7b0` then `SET_GLUE_SCREEN` (`0x402b65` at startup, `0x402090` back from the
|
||||
/// world). It holds what the glue state holds and nothing of the boot VM's: no `GlobalStrings.lua`,
|
||||
/// no emote tokens.
|
||||
fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool {
|
||||
let Some(mut script) = new_vm(world, UiScript::new_glue) else {
|
||||
return false;
|
||||
};
|
||||
seat_raster_seam_for_load(world, &mut script);
|
||||
seed_agreements(world, &mut script);
|
||||
let start = std::time::Instant::now();
|
||||
let failures = super::glue::load_glue(&mut script);
|
||||
super::glue::finish_glue_load_log(&mut script);
|
||||
start_server_alert(world, &script);
|
||||
super::glue::set_glue_screen(&mut script, screen);
|
||||
info!(
|
||||
"ui_script: stock glue up in {:.0} ms on \"{}\" ({} load failure(s))",
|
||||
start.elapsed().as_secs_f32() * 1000.0,
|
||||
screen.key(),
|
||||
failures.len()
|
||||
);
|
||||
// Up means the screen that asks for the roster exists: the stock files loaded far enough to
|
||||
// define the character select frame and its `OnShow`, not merely that a VM started.
|
||||
let charselect = script
|
||||
.eval::<bool>(
|
||||
"return type(CharacterSelect) == 'table' and type(CharacterSelect_OnShow) == 'function'",
|
||||
)
|
||||
.unwrap_or(false);
|
||||
world.insert_non_send_resource(script);
|
||||
charselect
|
||||
}
|
||||
|
||||
/// Start the server alert's fetch, as the glue manager's init does after the GlueXML load and
|
||||
/// before the first screen (`0x46a541`-`0x46a564`, `0x402b36`): the global `SERVER_ALERT_URL` as
|
||||
/// the glue files left it, so `GlueLocalization` wins over `GlueStrings`, fetched once per
|
||||
/// process ([`super::server_alert::begin_once`]).
|
||||
fn start_server_alert(world: &mut World, script: &UiScript) {
|
||||
let url = script.global_text("SERVER_ALERT_URL");
|
||||
if let Some(fetch) = super::server_alert::begin_once(&url) {
|
||||
if let Some(mut mgr) = world.get_resource_mut::<super::glue_mgr::GlueMgr>() {
|
||||
mgr.set_alert_fetch(fetch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the four agreement CVars into the glue VM and the manager before any screen is set, as
|
||||
/// the reference does (`0x46a400` at `0x402b36`, then `SET_GLUE_SCREEN` at `0x402b65`): the login
|
||||
/// screen's `OnShow` asks them at once, and nothing asks again.
|
||||
fn seed_agreements(world: &mut World, script: &mut UiScript) {
|
||||
let Some(cvars) = world.get_resource::<crate::cvars::Cvars>() else {
|
||||
return;
|
||||
};
|
||||
let notices = super::glue_mgr::read_agreements(cvars);
|
||||
for notice in GlueNotice::ALL {
|
||||
script.set_glue_notice(notice, notices[notice as usize]);
|
||||
}
|
||||
if let Some(mut mgr) = world.get_resource_mut::<super::glue_mgr::GlueMgr>() {
|
||||
mgr.set_notices(notices);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh VM from `make` on the process clock with the addon asset probes wired, or `None` (the
|
||||
/// world's VM removed) when the VM cannot start.
|
||||
fn new_vm<E: std::fmt::Display>(
|
||||
world: &mut World,
|
||||
make: fn() -> Result<UiScript, E>,
|
||||
) -> Option<UiScript> {
|
||||
let mut script = match make() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("ui_script: VM init failed: {e}");
|
||||
@@ -155,7 +254,7 @@ pub(crate) fn arm_entry_ui_load(world: &mut World) {
|
||||
/// `GlobalStrings.lua` and `Fonts.xml` as its first rows, and `ChatFrame.lua` the emote tokens.
|
||||
fn mint_entry_vm(world: &mut World) {
|
||||
crate::cvars::fold_dying_vm_cvars(world);
|
||||
if let Some(script) = new_vm(world) {
|
||||
if let Some(script) = new_vm(world, UiScript::new) {
|
||||
world.insert_non_send_resource(script);
|
||||
}
|
||||
}
|
||||
@@ -657,8 +756,24 @@ pub(crate) fn shutdown_ui_state(
|
||||
/// shutdown tail, then end this session's Lua state, so no frame, global or addon upvalue reaches
|
||||
/// the next login. The reference's state outlives `0x490bd0` and is replaced twice through
|
||||
/// `0x703b80`, in `ShutdownGame` (at `0x491231`) and then in the glue build (`0x46a7b0`), so the
|
||||
/// character screen runs on a state of its own. Ours is a fresh boot VM.
|
||||
/// character screen runs on a state of its own. Ours is a fresh boot VM, or the stock glue's
|
||||
/// ([`install_glue_side_vm`]).
|
||||
pub(crate) fn end_ui_session(world: &mut World) {
|
||||
close_ui_session(world);
|
||||
// `0x401ee0`'s glue leg: `"charselect"` after a logout, `"login"` after a disconnect
|
||||
// (`0x402084`); the state this exit enters says which.
|
||||
let screen = match world
|
||||
.get_resource::<State<crate::char_select::ClientState>>()
|
||||
.map(|s| *s.get())
|
||||
{
|
||||
Some(crate::char_select::ClientState::CharSelect) => super::glue::GlueScreen::CharSelect,
|
||||
_ => super::glue::GlueScreen::Login,
|
||||
};
|
||||
install_glue_side_vm(world, screen);
|
||||
}
|
||||
|
||||
/// [`end_ui_session`] up to the new VM: the shutdown tail, the folds and the host values reset.
|
||||
fn close_ui_session(world: &mut World) {
|
||||
// A VM still parked (the load never ran) goes back into the world first.
|
||||
unpark_boot_vm(world);
|
||||
// An entry load still armed means no in-game UI was built, and the tail would overwrite the
|
||||
@@ -699,8 +814,6 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
|
||||
minimap.0 = None;
|
||||
}
|
||||
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// A `ReloadUI()` waiting to run: set when [`crate::ui_logout`] drains
|
||||
@@ -731,7 +844,10 @@ pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
return;
|
||||
}
|
||||
info!("ui_script: ReloadUI — ending the UI session and building a new one");
|
||||
end_ui_session(world);
|
||||
// A boot VM, never the glue's: the reload builds no glue (`0x495664` then `0x495669`), and the
|
||||
// load replaces this VM at once.
|
||||
close_ui_session(world);
|
||||
install_boot_vm(world);
|
||||
load_ingame_ui_seated(world, LoadSeat::Live);
|
||||
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
|
||||
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
|
||||
@@ -994,6 +1110,108 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The IO thread's roster policy follows the VM that came up: the screen asks only when the
|
||||
/// stock glue VM stands, so a boot VM leaves the park to fetch the roster.
|
||||
#[test]
|
||||
fn the_roster_policy_follows_the_vm_that_stands() {
|
||||
use std::sync::atomic::Ordering;
|
||||
for stock in [true, false] {
|
||||
let mut world = World::new();
|
||||
let policy = crate::net::RosterPolicy(Default::default());
|
||||
policy.0.store(!stock, Ordering::SeqCst);
|
||||
world.insert_resource(policy.clone());
|
||||
world.insert_resource(super::super::glue::GlueStock(stock));
|
||||
world.insert_resource(State::new(crate::char_select::ClientState::Login));
|
||||
setup_script(&mut world);
|
||||
// The stock policy needs the stock files to have loaded: no install, no screen.
|
||||
let up = stock && benilla_formats::wow_data().is_some();
|
||||
assert_eq!(policy.0.load(Ordering::SeqCst), up, "stock {stock}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The agreements are read from their CVars before the first screen is set (`0x46a400` at
|
||||
/// `0x402b36`, `SET_GLUE_SCREEN` at `0x402b65`): a returning player's login shows the boxes,
|
||||
/// not the EULA, and a first launch still shows the EULA.
|
||||
#[test]
|
||||
fn the_login_screen_comes_up_on_the_accepted_agreements() {
|
||||
use crate::char_select::ClientState;
|
||||
let _ = benilla_formats::wow_data_or_skip!();
|
||||
let screens = |cvars: crate::cvars::Cvars| {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(super::super::glue::GlueStock(true));
|
||||
world.insert_resource(State::new(ClientState::Login));
|
||||
world.insert_resource(cvars);
|
||||
world.init_resource::<super::super::glue_mgr::GlueMgr>();
|
||||
setup_script(&mut world);
|
||||
world
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<(bool, bool, bool)>(
|
||||
"return TOSFrame:IsShown() == 1, AccountLoginUI:IsShown() == 1, \
|
||||
AccountLoginAccountEdit:IsVisible() == 1",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let mut accepted = crate::cvars::Cvars::with_value("readEULA", "1");
|
||||
accepted.set("readTOS", "1");
|
||||
assert_eq!(
|
||||
screens(accepted),
|
||||
(false, true, true),
|
||||
"accepted agreements go straight to the login boxes"
|
||||
);
|
||||
assert_eq!(
|
||||
screens(crate::cvars::Cvars::default()),
|
||||
(true, false, false),
|
||||
"a first launch is asked for the EULA"
|
||||
);
|
||||
}
|
||||
|
||||
/// Under `WOW_GLUE_STOCK=1` the world holds the glue VM where it holds a boot VM, at startup
|
||||
/// on the login screen (`0x402b65`) and back from the world on the screen the exit enters
|
||||
/// (`0x402090`); a `ReloadUI()` never builds the glue.
|
||||
#[test]
|
||||
fn the_stock_glue_stands_where_the_boot_vm_does() {
|
||||
use crate::char_select::ClientState;
|
||||
use benilla_ui::script::VmKind;
|
||||
let shown = |world: &World, frame: &str| {
|
||||
benilla_formats::wow_data().is_none()
|
||||
|| world
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<bool>(&format!("return {frame}:IsShown() == 1"))
|
||||
.unwrap()
|
||||
};
|
||||
let mut world = World::new();
|
||||
world.insert_resource(super::super::glue::GlueStock(true));
|
||||
world.insert_resource(State::new(ClientState::Login));
|
||||
setup_script(&mut world);
|
||||
assert_eq!(
|
||||
world.non_send_resource::<UiScript>().vm_kind(),
|
||||
VmKind::Glue
|
||||
);
|
||||
assert!(shown(&world, "AccountLogin"), "the boot screen is login");
|
||||
|
||||
world.insert_resource(State::new(ClientState::CharSelect));
|
||||
world.init_resource::<PendingEntryUiLoad>();
|
||||
end_ui_session(&mut world);
|
||||
assert_eq!(
|
||||
world.non_send_resource::<UiScript>().vm_kind(),
|
||||
VmKind::Glue
|
||||
);
|
||||
assert!(
|
||||
shown(&world, "CharacterSelect"),
|
||||
"a logout puts the character screen up"
|
||||
);
|
||||
|
||||
world.insert_resource(State::new(ClientState::InWorld));
|
||||
world.insert_resource(crate::run_mode::CaptureMode);
|
||||
world.insert_resource(ReloadUiPending(true));
|
||||
run_pending_reload(&mut world);
|
||||
assert_eq!(
|
||||
world.non_send_resource::<UiScript>().vm_kind(),
|
||||
VmKind::InGame,
|
||||
"the reload's VM is a boot VM, never the glue's"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`).
|
||||
#[test]
|
||||
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! `Logs\FrameXML.log` (`0x842ecc`): the UI loads' records ([`benilla_ui::status`]), written a
|
||||
//! line at a time as the reference's log layer writes one (`0x65ac20`): the stamp
|
||||
//! `"%u/%u %02u:%02u:%02u.%03u "` (`0x866aa0`), the text, then `"\r\n"` (`0x82edfc`). Deviation:
|
||||
//! the file is `benilla-config/Logs/FrameXML.log`, since the install is read-only.
|
||||
//! `"%u/%u %02u:%02u:%02u.%03u "` (`0x866aa0`), the text, then `"\r\n"` (`0x82edfc`). The glue
|
||||
//! build writes its own `Logs\GlueXML.log` (`0x837024`, opened at `0x46a8ce`) the same way.
|
||||
//! Deviation: the files are under `benilla-config/Logs/`, since the install is read-only.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
@@ -9,20 +10,32 @@ use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::status::LogWrite;
|
||||
|
||||
/// The log's name, the reference's.
|
||||
/// The in-game log's name, the reference's.
|
||||
const FILE: &str = "FrameXML.log";
|
||||
|
||||
/// Write the VM's drains in the order they were made. A drain with no lines opens nothing, so a
|
||||
/// clean UI load leaves the last load's file in place, as the reference's lazy open does
|
||||
/// (`0x65a930`).
|
||||
/// The glue log's name, the reference's.
|
||||
const GLUE_FILE: &str = "GlueXML.log";
|
||||
|
||||
/// Write the in-game VM's drains in the order they were made. A drain with no lines opens
|
||||
/// nothing, so a clean UI load leaves the last load's file in place, as the reference's lazy open
|
||||
/// does (`0x65a930`).
|
||||
pub(super) fn write(writes: Vec<LogWrite>) {
|
||||
write_named(FILE, writes);
|
||||
}
|
||||
|
||||
/// [`write`] for the glue VM's drains, into the glue's own log.
|
||||
pub(super) fn write_glue(writes: Vec<LogWrite>) {
|
||||
write_named(GLUE_FILE, writes);
|
||||
}
|
||||
|
||||
fn write_named(file: &str, writes: Vec<LogWrite>) {
|
||||
if writes.iter().all(|w| lines(w).is_empty()) {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = crate::local_state::logs_dir() else {
|
||||
return; // hermetic capture, or no state folder
|
||||
};
|
||||
write_under(&dir, writes);
|
||||
write_under(&dir, file, writes);
|
||||
}
|
||||
|
||||
fn lines(write: &LogWrite) -> &[String] {
|
||||
@@ -31,9 +44,9 @@ fn lines(write: &LogWrite) -> &[String] {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`write`] into `dir`.
|
||||
fn write_under(dir: &std::path::Path, writes: Vec<LogWrite>) {
|
||||
let path = dir.join(FILE);
|
||||
/// [`write_named`] into `dir`.
|
||||
fn write_under(dir: &std::path::Path, file: &str, writes: Vec<LogWrite>) {
|
||||
let path = dir.join(file);
|
||||
for w in writes {
|
||||
let (append, lines) = match w {
|
||||
LogWrite::Rewrite(lines) => (false, lines),
|
||||
@@ -84,11 +97,19 @@ mod tests {
|
||||
fn a_load_rewrites_a_load_addon_appends_and_a_clean_load_touches_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("benilla-framexml-log-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
write_under(&dir, vec![LogWrite::Rewrite(vec!["first load".into()])]);
|
||||
write_under(&dir, vec![LogWrite::Rewrite(vec!["second load".into()])]);
|
||||
write_under(&dir, vec![LogWrite::Append(vec!["on demand".into()])]);
|
||||
write_under(
|
||||
&dir,
|
||||
FILE,
|
||||
vec![LogWrite::Rewrite(vec!["first load".into()])],
|
||||
);
|
||||
write_under(
|
||||
&dir,
|
||||
FILE,
|
||||
vec![LogWrite::Rewrite(vec!["second load".into()])],
|
||||
);
|
||||
write_under(&dir, FILE, vec![LogWrite::Append(vec!["on demand".into()])]);
|
||||
assert_eq!(read(&dir), ["second load", "on demand"]);
|
||||
write_under(&dir, vec![LogWrite::Rewrite(vec![])]);
|
||||
write_under(&dir, FILE, vec![LogWrite::Rewrite(vec![])]);
|
||||
assert_eq!(read(&dir), ["second load", "on demand"]);
|
||||
let raw = std::fs::read_to_string(dir.join(FILE)).unwrap();
|
||||
let first = raw.split("\r\n").next().unwrap();
|
||||
|
||||
@@ -167,6 +167,11 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
|
||||
/// reports `"Couldn't open %s"` (`0x846ff4`) at severity 2 (`0x6edc3f`) and returns 0, which
|
||||
/// `UI_Init` never reads (`0x48fff2`), going on to `Bindings.xml` and the addons. benilla goes on
|
||||
/// the same way and reports it as a load failure, an `ERROR` line and a row in `/errors`.
|
||||
///
|
||||
/// Deviation: no `FrameXML.toc` signature check, where `UI_Init` loads on a failed one and then
|
||||
/// quits (`0x401560(0xa)` at `0x48ff90`), because the check verifies Blizzard's signature with a key
|
||||
/// carried in the client binary, which benilla does not ship, and a modified interface file is
|
||||
/// loaded rather than refused.
|
||||
pub(super) fn load_core(script: &UiScript) -> Vec<String> {
|
||||
let Some(core) = reference_ui::core() else {
|
||||
let e = format!(
|
||||
@@ -485,10 +490,12 @@ mod tests {
|
||||
assert_eq!(order, ["ExhaustionTick", "PlayerFrame"]);
|
||||
}
|
||||
|
||||
/// A shipped `.xml` the layer does not list never loads, with no error to say so.
|
||||
/// A shipped `.xml` the layer does not list never loads, with no error to say so; the one
|
||||
/// glue file loads by name ([`super::super::glue::GLUE_LAYER`]).
|
||||
#[test]
|
||||
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
|
||||
let mut listed = shipped_manifest_files();
|
||||
listed.push(super::super::glue::GLUE_LAYER.to_string());
|
||||
let mut shipped: Vec<String> = super::super::content::shipped_files()
|
||||
.filter(|f| f.ends_with(".xml"))
|
||||
.map(str::to_owned)
|
||||
|
||||
@@ -16,7 +16,16 @@ use benilla_world::schedule::WorldStage;
|
||||
pub(crate) mod addons;
|
||||
mod content;
|
||||
pub(crate) mod extract;
|
||||
/// The stock glue screens behind `WOW_GLUE_STOCK=1`.
|
||||
pub(crate) mod glue;
|
||||
/// `CGlueMgr`'s screen states over the native login, for the stock glue screens.
|
||||
pub(crate) mod glue_mgr;
|
||||
pub(crate) mod glue_scene;
|
||||
mod input;
|
||||
/// The server alert's fetch and `LaunchURL`'s browser, for the stock glue screens.
|
||||
pub(crate) mod server_alert;
|
||||
#[cfg(test)]
|
||||
pub(crate) use input::feed_keys;
|
||||
#[cfg(test)]
|
||||
mod layer_tests;
|
||||
mod load_log;
|
||||
@@ -122,6 +131,12 @@ pub(crate) struct UiKeyboardCapture {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
|
||||
|
||||
/// A press the UI consumed this frame, whatever is under the pointer: the layout drag's press
|
||||
/// returns at `0x766353`–`0x76635b`, before any frame, the world frame included, sees it, so the
|
||||
/// world latch takes no press this frame even with no UI frame hovered.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PlayerUiPressConsumed(pub(crate) bool);
|
||||
|
||||
/// Whether the cursor carries a payload, a `Send` mirror. A click on sky with one held never
|
||||
/// deselects: the reference gates its nothing-leg `SetSelection(0,0)` on no payload (`0x492d30`).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -283,6 +298,8 @@ impl Plugin for UiScriptPlugin {
|
||||
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
|
||||
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
|
||||
app.insert_resource(UiScaleCvar(default_ui_scale()))
|
||||
.insert_resource(glue::GlueStock::from_env())
|
||||
.init_resource::<glue_mgr::GlueMgr>()
|
||||
.init_resource::<UiAutoScaleCache>()
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<crate::bindings::WheelNotches>()
|
||||
@@ -298,11 +315,18 @@ impl Plugin for UiScriptPlugin {
|
||||
.init_resource::<PlayerUiHover>()
|
||||
.init_resource::<UiKeyboardCapture>()
|
||||
.init_resource::<PlayerUiClickConsumed>()
|
||||
.init_resource::<PlayerUiPressConsumed>()
|
||||
.init_resource::<CursorPayloadHeld>()
|
||||
.init_resource::<UiClock>()
|
||||
.init_resource::<AddOnIdentity>()
|
||||
// After `AssetSet::Open`: the VM's first load is the chain's `GlobalStrings.lua`.
|
||||
.add_systems(Startup, setup_script.after(benilla_assets::AssetSet::Open))
|
||||
.add_systems(
|
||||
Startup,
|
||||
// After the CVars load: the glue screens read the agreements as they come up.
|
||||
setup_script
|
||||
.after(benilla_assets::AssetSet::Open)
|
||||
.after(crate::cvars::CvarLoad),
|
||||
)
|
||||
// The in-game UI loads on world entry, as the reference's does, once the loading cover
|
||||
// has presented, so its ~0.5 s never stalls the cover's first frame.
|
||||
.add_systems(
|
||||
@@ -338,14 +362,19 @@ impl Plugin for UiScriptPlugin {
|
||||
)
|
||||
// In the feed phase, so an `OnUpdate` reads this frame's `GetFramerate()`.
|
||||
.add_systems(Update, feed_framerate.in_set(UiFeed))
|
||||
// The input pass hit-tests the rects the tick just resolved, and runs in-world only.
|
||||
// The input pass hit-tests the rects the tick just resolved, and runs in-world only or
|
||||
// on the stock glue screens.
|
||||
.init_resource::<UiPassState>()
|
||||
.init_resource::<PointerOverUiPanel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
extract::tick_script,
|
||||
input::feed_ui_input.in_set(crate::char_select::InWorldGated),
|
||||
input::feed_ui_input.run_if(
|
||||
in_state(crate::char_select::ClientState::InWorld).or(glue::glue_ui_up),
|
||||
),
|
||||
// Off the walk that just ran, for the pick and the arbiter after the pass.
|
||||
crate::vplates::seed_plate_hover,
|
||||
)
|
||||
.chain()
|
||||
.in_set(UiInput)
|
||||
@@ -396,15 +425,16 @@ fn arbitrate_pointer_over_ui(
|
||||
) {
|
||||
// No cinematic term: the hit test finds the full-screen, mouse-enabled `CinematicFrame`.
|
||||
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
|
||||
// Chrome excludes the plates; the plate hover is last frame's, as the plate driver runs later.
|
||||
// Chrome excludes the plates, whose hover is this frame's walk's.
|
||||
panel.0 = over.0 && plate_hover.0.is_none();
|
||||
}
|
||||
|
||||
/// The session lifecycle: the VM's birth, identity, death and reload.
|
||||
mod lifecycle;
|
||||
pub(crate) use lifecycle::{
|
||||
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep,
|
||||
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage,
|
||||
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, strings_vm, AddOnIdentity,
|
||||
LeaveWorldSweep, LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending,
|
||||
WorldLeaveSweepMessage,
|
||||
};
|
||||
// Test-only: other modules' tests consume these, and a plain re-export would warn unused.
|
||||
#[cfg(test)]
|
||||
@@ -702,6 +732,10 @@ pub(crate) fn load_ui_for_test(script: &benilla_ui::script::UiScript, entry: &st
|
||||
|
||||
#[cfg(test)]
|
||||
mod cinematic_tests;
|
||||
#[cfg(test)]
|
||||
mod glue_input_tests;
|
||||
#[cfg(test)]
|
||||
mod key_walk_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cast_tests;
|
||||
|
||||
@@ -840,9 +840,16 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
|
||||
let occupied = ring_alphas(&s, quickslot2);
|
||||
assert_eq!(occupied, vec![0.5], "the occupied ring ghosts too");
|
||||
|
||||
// Filling a ghosted well runs `ActionButton_UpdateUsable` (ActionButton.lua:269-283), whose
|
||||
// three-argument `SetVertexColor(1, 1, 1)` sets alpha 1.0 in this engine; the reference keeps
|
||||
// the region's alpha (`0x79abd0`), which would leave this ring dim.
|
||||
// Filling a ghosted well runs `ActionButton_Update` (ActionButton.lua:162, 170). Its
|
||||
// `SetNormalTexture(path)` (`0x781960` -> `0x781970`) reuses the XML-defined region in
|
||||
// `[button + 4*state + 0x4b8]` (`0x778f10`) and sets only its file through `0x770200`, which
|
||||
// writes `[tex+0xcc]` and `[tex+0x128]` and redraws (`0x77fdf0`); the colour array
|
||||
// (`+0xa4/+0xa8/+0xb4/+0xb8`) is written by `0x77f750` alone, whose callers are the Lua
|
||||
// colour setters and the XML loader, none a button state change. Then
|
||||
// `ActionButton_UpdateUsable` (:274-275, :281) calls the three-argument
|
||||
// `SetVertexColor(1, 1, 1)`, which keeps the region's alpha (`0x79abd0` re-tests the alpha
|
||||
// gate at `0x79adfe` and copies the old byte back at `0x79ae0a`). So the filled well's ring
|
||||
// stays at the grid's 0.5 like the occupied one.
|
||||
s.set_action(
|
||||
2,
|
||||
Some(ActionSlot {
|
||||
@@ -857,9 +864,8 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
ring_alphas(&s, quickslot2),
|
||||
vec![0.5, 1.0],
|
||||
"the well that got an action is opaque again (its Update ran UpdateUsable); the other \
|
||||
occupied ring keeps its ghost, payload still held"
|
||||
vec![0.5, 0.5],
|
||||
"the filled well's UpdateUsable keeps the ghost alpha, as the occupied ring's does"
|
||||
);
|
||||
|
||||
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
|
||||
@@ -868,12 +874,14 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
|
||||
let after = ring_alphas(&s, quickslot2);
|
||||
assert_eq!(
|
||||
after,
|
||||
vec![0.5, 1.0],
|
||||
"HideGrid touches no colour: a ring the grid ghosted stays dim past the payload — the \
|
||||
reference's own rule (ours used to clear it here)"
|
||||
vec![0.5, 0.5],
|
||||
"HideGrid touches no colour (ActionButton.lua:251-258): every ring the grid ghosted stays \
|
||||
dim past the payload"
|
||||
);
|
||||
// The ghosted ring's next `ActionButton_UpdateUsable` is not asserted: this engine resets its
|
||||
// alpha to 1.0, the reference keeps 0.5 (`0x79abd0`).
|
||||
// A later `ActionButton_UpdateUsable` (a target change fires it) keeps the dim alpha too.
|
||||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||||
s.resolve();
|
||||
assert_eq!(ring_alphas(&s, quickslot2), vec![0.5, 0.5]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
|
||||
let ground = quads
|
||||
.iter()
|
||||
.position(|q| match &q.content {
|
||||
QuadContent::Backdrop { path, .. } => path.contains("UI-DialogBox-Background"),
|
||||
QuadContent::Texture { path: Some(p), .. } => p.contains("UI-DialogBox-Background"),
|
||||
_ => false,
|
||||
})
|
||||
.expect("the window still wears the 1.14 dialog ground");
|
||||
@@ -2493,16 +2493,23 @@ fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
|
||||
"{f} stays away while the page fits"
|
||||
);
|
||||
}
|
||||
let page_h: f32 = s
|
||||
.eval("return BenillaOptionsFrameContainerScroll:GetHeight()")
|
||||
// The page rect in the window's units, from its edges: the scroll is sized by its anchors,
|
||||
// so its `GetWidth`/`GetHeight` answer the span before the window's scale divides it.
|
||||
let (page_w, page_h): (f32, f32) = s
|
||||
.eval(
|
||||
"local sf = BenillaOptionsFrameContainerScroll \
|
||||
return sf:GetRight() - sf:GetLeft(), sf:GetTop() - sf:GetBottom()",
|
||||
)
|
||||
.unwrap();
|
||||
let (body_w, body_h): (f32, f32) = s
|
||||
.eval(
|
||||
"return BenillaOptionsFrameContainerBody:GetWidth(), \
|
||||
BenillaOptionsFrameContainerBody:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(s.eval::<f32>("return BenillaOptionsFrameContainerBody:GetHeight()")
|
||||
.unwrap()
|
||||
- page_h)
|
||||
.abs()
|
||||
< 0.5,
|
||||
"the body is the page rect when nothing overflows"
|
||||
(body_w - page_w).abs() < 0.5 && (body_h - page_h).abs() < 0.5,
|
||||
"the body is the page rect when nothing overflows: {body_w}x{body_h} vs {page_w}x{page_h}"
|
||||
);
|
||||
|
||||
s.run("BenillaOptionsFrameSearchBox:SetText(\"e\")")
|
||||
@@ -2700,7 +2707,8 @@ fn hover_label(s: &mut UiScript, frame: &str) {
|
||||
|
||||
/// Scroll `frame` into the page's rect: past the fold a row is clipped and cannot be hovered.
|
||||
fn scroll_into_view(s: &mut UiScript, frame: &str) {
|
||||
s.resolve();
|
||||
// The page's fit and the range it marks land in the walk.
|
||||
settle(s);
|
||||
s.run(&format!(
|
||||
"local sf = BenillaOptionsFrameContainerScroll \
|
||||
local range = sf:GetVerticalScrollRange() \
|
||||
|
||||
@@ -301,6 +301,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run("MyScroll:UpdateScrollChildRect()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"ScrollFrame_OnScrollRangeChanged ran clean: {:?}",
|
||||
|
||||
@@ -1052,6 +1052,7 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measures reach the scroll range and the bar
|
||||
s.tick(0.016); // the marked range is recomputed in the frame's update
|
||||
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
@@ -1109,7 +1110,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measures reach the scroll range and the bar
|
||||
s.resolve(); // the range reads resolved rects
|
||||
s.tick(0.016); // the marked range is recomputed in the frame's update
|
||||
|
||||
// Scroll through the bar, as the wheel does: a reselect snaps back with the bar's
|
||||
// `SetValue(0)`, which changes nothing on a bar already at 0.
|
||||
@@ -1262,7 +1263,7 @@ fn popup_children_inherit_the_dialog_stratum() {
|
||||
let backdrop_z = quads
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Backdrop { path, .. } if path.contains("UI-DialogBox-Background") => {
|
||||
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-DialogBox-Background") => {
|
||||
Some(q.z)
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -133,7 +133,7 @@ fn seat_a_hunter(root: &Path) -> UiScript {
|
||||
});
|
||||
}
|
||||
|
||||
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"));
|
||||
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"), false);
|
||||
s.register_addons(vec![info], Some(root.to_path_buf()), None, None);
|
||||
// The in-game UI loads on world entry, so a player always exists by then.
|
||||
s.set_unit(
|
||||
|
||||
@@ -47,9 +47,29 @@ pub(super) fn core() -> Option<Addon> {
|
||||
/// order, as the runner builds its paths (`0x6edbe5`, `0x6edcf4`, `0x6edd0c`) and dispatches each
|
||||
/// in turn (`0x6edd51`).
|
||||
pub(super) fn core_from(toc: &str) -> Addon {
|
||||
let dir = TOC.rsplit_once('\\').map_or("", |(d, _)| d);
|
||||
addon(
|
||||
Toc::parse(toc)
|
||||
toc_addon(NAME, TOC, toc)
|
||||
}
|
||||
|
||||
/// The glue screens' table of contents, which the glue builder (`0x46a7b0`) hands to the same
|
||||
/// `.toc` runner (`0x6edb90`) at `0x46a9b3`, once per glue build.
|
||||
pub(super) const GLUE_TOC: &str = r"Interface\GlueXML\GlueXML.toc";
|
||||
|
||||
/// The name the stock glue files load under, as [`NAME`] is the in-game core's.
|
||||
pub(super) const GLUE_NAME: &str = "GlueXML";
|
||||
|
||||
/// The glue screens' interface: the chain's own [`GLUE_TOC`] as an [`Addon`] over the chain, or
|
||||
/// `None` when the chain has no such file.
|
||||
pub(super) fn glue() -> Option<Addon> {
|
||||
read(GLUE_TOC).map(|bytes| toc_addon(GLUE_NAME, GLUE_TOC, &benilla_ui::source::decode(&bytes)))
|
||||
}
|
||||
|
||||
/// The interface `toc_text` lists, the `.toc` at chain path `toc_path`: its rows under the toc's
|
||||
/// own directory, in its order.
|
||||
fn toc_addon(name: &str, toc_path: &str, toc_text: &str) -> Addon {
|
||||
let dir = toc_path.rsplit_once('\\').map_or("", |(d, _)| d);
|
||||
named_addon(
|
||||
name,
|
||||
Toc::parse(toc_text)
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| format!("{dir}\\{f}"))
|
||||
@@ -58,9 +78,15 @@ pub(super) fn core_from(toc: &str) -> Addon {
|
||||
}
|
||||
|
||||
/// The stock interface as an [`Addon`] over the chain; `files` are full chain paths.
|
||||
#[cfg(test)]
|
||||
pub(super) fn addon(files: Vec<String>) -> Addon {
|
||||
named_addon(NAME, files)
|
||||
}
|
||||
|
||||
/// [`addon`] under `name`.
|
||||
fn named_addon(name: &str, files: Vec<String>) -> Addon {
|
||||
Addon::new(
|
||||
NAME.to_string(),
|
||||
name.to_string(),
|
||||
Toc {
|
||||
directives: Vec::new(),
|
||||
files,
|
||||
@@ -76,6 +102,9 @@ pub(super) fn read(req: &str) -> Option<Vec<u8>> {
|
||||
if let Some(overlaid) = fixture::read(req) {
|
||||
return overlaid;
|
||||
}
|
||||
if let Some(bytes) = read_loose(req) {
|
||||
return Some(bytes);
|
||||
}
|
||||
let chain = chain()?;
|
||||
match chain.read(req) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
@@ -86,6 +115,53 @@ pub(super) fn read(req: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare file name off the install's own folders, read-only: Storm's open tries the install
|
||||
/// directory (its base path `0xc52418`) and its `Data\` before the archives, which is where the
|
||||
/// login screen's agreements (`<SimpleHTML file="eula.html">`) are found, loose in `Data\`. The
|
||||
/// lookup ignores case, as the reference's file system does, so `credits.html` finds
|
||||
/// `Data/Credits.html` on a case-sensitive one. Only a bare name looks on disk; every chain path
|
||||
/// keeps reading the archives alone.
|
||||
fn read_loose(req: &str) -> Option<Vec<u8>> {
|
||||
if req.is_empty() || req.contains(['\\', '/']) || req.contains("..") {
|
||||
return None;
|
||||
}
|
||||
static INDEX: OnceLock<std::collections::HashMap<String, std::path::PathBuf>> = OnceLock::new();
|
||||
let index = INDEX.get_or_init(|| {
|
||||
benilla_formats::wow_data()
|
||||
.map(|data| {
|
||||
let install = data.parent().map(std::path::Path::to_path_buf);
|
||||
let dirs: Vec<std::path::PathBuf> =
|
||||
install.into_iter().chain(std::iter::once(data)).collect();
|
||||
loose_index(&dirs)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
std::fs::read(index.get(&req.to_ascii_lowercase())?).ok()
|
||||
}
|
||||
|
||||
/// The files directly in `dirs` by lowercased name, read once; an earlier directory wins a name
|
||||
/// two share.
|
||||
fn loose_index(
|
||||
dirs: &[std::path::PathBuf],
|
||||
) -> std::collections::HashMap<String, std::path::PathBuf> {
|
||||
let mut index = std::collections::HashMap::new();
|
||||
for dir in dirs {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
index.entry(name.to_ascii_lowercase()).or_insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The player's patch chain, opened once per process and shared by every VM. Process-local, not
|
||||
/// the one [`benilla_assets`] holds: tests, the addon harness and a bare `UiScript` load the
|
||||
/// interface with no Bevy world to ask.
|
||||
@@ -163,6 +239,23 @@ fn is_word(c: char) -> bool {
|
||||
mod tests {
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
/// A bare name finds its loose file whatever the case on disk, the install root ahead of
|
||||
/// `Data`.
|
||||
#[test]
|
||||
fn the_loose_index_ignores_case_and_prefers_the_install_root() {
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-loose-{}", std::process::id()));
|
||||
let data = tmp.join("Data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
std::fs::write(data.join("Credits.html"), b"data").unwrap();
|
||||
std::fs::write(data.join("eula.html"), b"data").unwrap();
|
||||
std::fs::write(tmp.join("EULA.html"), b"root").unwrap();
|
||||
let index = super::loose_index(&[tmp.clone(), data.clone()]);
|
||||
let read = |name: &str| std::fs::read(&index[name]).unwrap();
|
||||
assert_eq!(read("credits.html"), b"data");
|
||||
assert_eq!(read("eula.html"), b"root");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// Every `RegisterForSave`'d UI global defaults to the value the reference's own
|
||||
/// `UIOptionsFrame_Init` assigns, the FrameXML half of the rule `cvars::REGISTERED` holds for
|
||||
/// CVars. A name the reference declares elsewhere (`SHOW_OFFLINE_GUILD_MEMBERS`,
|
||||
@@ -2010,10 +2103,12 @@ mod tests {
|
||||
// rustfmt splits a long chain at its dots, so the three shapes are matched
|
||||
// with the whitespace before each `.` removed.
|
||||
let text = glue_chains(&text);
|
||||
// The engine's deferred lane, `pending_events.push((name, args))`, fires too.
|
||||
const CALLS: [&str; 3] = [
|
||||
// A verb's `model.raise(name, args)` and the host queue,
|
||||
// `pending_events.push((name, args))`, fire too.
|
||||
const CALLS: [&str; 4] = [
|
||||
concat!("fire_event", "("),
|
||||
concat!("fire_event_into", "("),
|
||||
concat!("model.raise", "("),
|
||||
concat!("pending_events.push", "(("),
|
||||
];
|
||||
let mut fires_indirectly = false;
|
||||
|
||||
@@ -73,6 +73,19 @@ fn app_frame_ticked(s: &mut UiScript, dt: f32) {
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// A forced rebuild counts a derivation: the positive control behind every zero asserted on
|
||||
/// `layout_derivations`, now that births derive nothing.
|
||||
fn derivations_move(s: &mut UiScript) {
|
||||
let before = s.layout_derivations();
|
||||
s.force_full_layout_resolve();
|
||||
s.resolve();
|
||||
assert!(
|
||||
s.layout_derivations() > before,
|
||||
"a forced rebuild must count a derivation — reading zero means `layout_derivations` never \
|
||||
moves and every zero asserted on it is vacuous"
|
||||
);
|
||||
}
|
||||
|
||||
/// A tooltip whose lines change width every call, as in a hover sweep across a bag grid.
|
||||
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
|
||||
s.run(&format!(
|
||||
@@ -127,6 +140,103 @@ fn tooltip_change_costs_a_whole_ui_solve() {
|
||||
);
|
||||
}
|
||||
|
||||
/// What hidden frames cost on the shipped UI, which keeps most of its frames hidden: the load and
|
||||
/// settle, a stock panel's first and later shows, and layout writes to hidden frames every frame.
|
||||
#[test]
|
||||
#[ignore = "bench, run explicitly"]
|
||||
fn hidden_frames_cost() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let t = Instant::now();
|
||||
super::load_default_ui(&s);
|
||||
let load = ms(t);
|
||||
let t = Instant::now();
|
||||
for _ in 0..12 {
|
||||
s.resolve();
|
||||
if answer_measures(&mut s) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"BOOT load {load:.1} ms settle {:.2} ms solves={} rounds={}",
|
||||
ms(t),
|
||||
s.layout_solves(),
|
||||
s.layout_rounds()
|
||||
);
|
||||
|
||||
let quiet = |s: &mut UiScript| {
|
||||
for _ in 0..4 {
|
||||
app_frame(s);
|
||||
}
|
||||
};
|
||||
quiet(&mut s);
|
||||
for panel in [
|
||||
"CharacterFrame",
|
||||
"SpellBookFrame",
|
||||
"QuestLogFrame",
|
||||
"FriendsFrame",
|
||||
"GameMenuFrame",
|
||||
"OptionsFrame",
|
||||
] {
|
||||
let t = Instant::now();
|
||||
s.run(&format!("{panel}:Show()")).unwrap();
|
||||
app_frame(&mut s);
|
||||
let first = ms(t);
|
||||
s.run(&format!("{panel}:Hide()")).unwrap();
|
||||
app_frame(&mut s);
|
||||
quiet(&mut s);
|
||||
let n = 20u32;
|
||||
let t = Instant::now();
|
||||
for _ in 0..n {
|
||||
s.run(&format!("{panel}:Show()")).unwrap();
|
||||
app_frame(&mut s);
|
||||
s.run(&format!("{panel}:Hide()")).unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
println!(
|
||||
"SHOW {panel:<15} first {first:.3} ms later {:.3} ms/show+hide",
|
||||
ms(t) / f64::from(n)
|
||||
);
|
||||
}
|
||||
|
||||
// A layout write to each of the spell book's hidden buttons, every frame.
|
||||
s.run(
|
||||
r#"
|
||||
bench_hidden_n = 0
|
||||
function bench_hidden_frame()
|
||||
bench_hidden_n = bench_hidden_n + 1
|
||||
for i = 1, 12 do
|
||||
getglobal("SpellButton" .. i):SetWidth(30 + math.mod(bench_hidden_n, 2))
|
||||
end
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
quiet(&mut s);
|
||||
let (s0, r0) = (s.layout_solves(), s.layout_rounds());
|
||||
let n = 100u32;
|
||||
let t = Instant::now();
|
||||
for _ in 0..n {
|
||||
s.run("bench_hidden_frame()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
println!(
|
||||
"HIDDEN {:.3} ms/frame solves/frame={:.2} rounds/frame={:.2}",
|
||||
ms(t) / f64::from(n),
|
||||
(s.layout_solves() - s0) as f64 / f64::from(n),
|
||||
(s.layout_rounds() - r0) as f64 / f64::from(n),
|
||||
);
|
||||
}
|
||||
|
||||
/// Measuring before resolving makes a tooltip content change cost one layout solve; measured
|
||||
/// after, the answers would force a second whole-UI solve.
|
||||
#[test]
|
||||
@@ -267,8 +377,7 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
|
||||
|
||||
/// Those walks cost zero derivations of the layout graph: a moving region names its node, so the
|
||||
/// resolve re-hashes one node, not the roster; a derivation per frame means a write site fell back
|
||||
/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too,
|
||||
/// and a birth must derive, which proves the counter moves.
|
||||
/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too.
|
||||
#[test]
|
||||
fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -290,18 +399,18 @@ fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Positive control: a birth moves the roster, so it must derive.
|
||||
// Births join the cached graph without a derivation.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..6 {
|
||||
s.run("bench_spark2_frame()").unwrap();
|
||||
app_frame_ticked(&mut s, 1.0 / 144.0);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating a frame and a texture must derive the graph — a birth moves the roster, which \
|
||||
is the one thing the per-node ledger cannot describe. Reading zero here means \
|
||||
`layout_derivations` never moves and the assertion below is vacuous."
|
||||
assert_eq!(
|
||||
s.layout_derivations() - born_at,
|
||||
0,
|
||||
"creating a frame and a texture derived the layout graph: a birth names its nodes, which join the cached graph"
|
||||
);
|
||||
derivations_move(&mut s);
|
||||
|
||||
let derives_before = s.layout_derivations();
|
||||
let walks_before = s.layout_gate_walks();
|
||||
@@ -435,17 +544,18 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Positive control: the owner's birth and the line pool's growth move the roster and derive.
|
||||
// Births join the cached graph without a derivation.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..8 {
|
||||
s.run("flip_change()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating the owner and growing the tooltip's line pool must derive the graph — reading \
|
||||
zero here means `layout_derivations` never moves and the assertion below is vacuous."
|
||||
assert_eq!(
|
||||
s.layout_derivations() - born_at,
|
||||
0,
|
||||
"creating the owner and growing the line pool derived the layout graph: a birth names its nodes, which join the cached graph"
|
||||
);
|
||||
derivations_move(&mut s);
|
||||
|
||||
let derives_before = s.layout_derivations();
|
||||
for _ in 0..10 {
|
||||
@@ -493,17 +603,18 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Positive control: twelve births and the line pool's growth move the roster and derive.
|
||||
// Births join the cached graph without a derivation.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..40 {
|
||||
s.run("sweep_change()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating twelve owners and growing the line pool must derive the graph — reading zero \
|
||||
here means `layout_derivations` never moves and the assertion below proves nothing."
|
||||
assert_eq!(
|
||||
s.layout_derivations() - born_at,
|
||||
0,
|
||||
"creating twelve owners and growing the line pool derived the layout graph: a birth names its nodes, which join the cached graph"
|
||||
);
|
||||
derivations_move(&mut s);
|
||||
|
||||
// Two laps of the twelve owners: every step re-hovers a button the graph already knows.
|
||||
let derives_before = s.layout_derivations();
|
||||
@@ -515,9 +626,9 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
|
||||
assert_eq!(
|
||||
derives, 0,
|
||||
"24 hovers across 12 owners derived the layout graph {derives} times — one per slot \
|
||||
crossed. `SetOwner` re-points ONE node's anchor and must patch that node's edges \
|
||||
(`Model::touch_layout_retarget_frame`); the conservative touch re-derives the whole \
|
||||
roster on every slot the cursor passes over."
|
||||
crossed. `SetOwner` re-points ONE node's anchor and must name that node \
|
||||
(`Model::touch_layout_frame`), whose edges re-derive in the cached graph; the \
|
||||
conservative touch re-derives the whole roster on every slot the cursor passes over."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -556,16 +667,18 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Positive control: the births and the line pool's growth are structural.
|
||||
// Births join the cached graph without a derivation.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..40 {
|
||||
s.run("bar_hover()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating twelve buttons must derive the graph — zero here makes the assertion vacuous."
|
||||
assert_eq!(
|
||||
s.layout_derivations() - born_at,
|
||||
0,
|
||||
"creating twelve buttons derived the layout graph: a birth names its nodes, which join the cached graph"
|
||||
);
|
||||
derivations_move(&mut s);
|
||||
|
||||
let derives_before = s.layout_derivations();
|
||||
for _ in 0..24 {
|
||||
@@ -623,16 +736,18 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Positive control: the births and the line pool's growth are structural.
|
||||
// Births join the cached graph without a derivation.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..40 {
|
||||
s.run("bag_hover()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating twelve slots must derive the graph — zero here makes the assertion vacuous."
|
||||
assert_eq!(
|
||||
s.layout_derivations() - born_at,
|
||||
0,
|
||||
"creating twelve slots derived the layout graph: a birth names its nodes, which join the cached graph"
|
||||
);
|
||||
derivations_move(&mut s);
|
||||
|
||||
let derives_before = s.layout_derivations();
|
||||
for _ in 0..24 {
|
||||
@@ -647,3 +762,95 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
|
||||
EMPTY set and names its node like any other."
|
||||
);
|
||||
}
|
||||
|
||||
/// What one structural change costs on the full shipped UI, per run, resolve and extract: a no-op,
|
||||
/// a `SetWidth`, a `CreateTexture`, a `CreateFrame` and a backdrop rebuild, each as the mean of
|
||||
/// 200, with the graph derivations it paid beside it.
|
||||
#[test]
|
||||
#[ignore = "bench, run explicitly"]
|
||||
fn structural_change_costs() {
|
||||
let mut s = settled_default_ui();
|
||||
s.run(
|
||||
r#"
|
||||
BenchHost = CreateFrame("Frame", "BenchHost", UIParent)
|
||||
BenchHost:SetPoint("CENTER", 0, 0); BenchHost:SetWidth(200); BenchHost:SetHeight(100)
|
||||
BenchText = BenchHost:CreateFontString(nil, "ARTWORK", "GameFontNormal")
|
||||
BenchText:SetPoint("TOPLEFT", BenchHost, "TOPLEFT", 4, -4); BenchText:SetText("bench")
|
||||
BenchArt = BenchHost:CreateTexture(nil, "ARTWORK")
|
||||
BenchArt:SetPoint("BOTTOMLEFT", BenchHost, "BOTTOMLEFT", 4, 4)
|
||||
BenchArt:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
|
||||
bench_n = 0
|
||||
bench_spec = { bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
||||
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16,
|
||||
edgeSize = 16, insets = { left = 4, right = 4, top = 4, bottom = 4 } }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
for _ in 0..4 {
|
||||
app_frame(&mut s);
|
||||
}
|
||||
let cases: [(&str, &str); 9] = [
|
||||
("no-op", "bench_n = bench_n + 1"),
|
||||
// Setters an OnUpdate calls every frame with the value already held: each is a write the
|
||||
// reference invalidates for, and must not re-resolve the world.
|
||||
(
|
||||
"SetWidth same",
|
||||
"bench_n = bench_n + 1; BenchHost:SetWidth(200)",
|
||||
),
|
||||
(
|
||||
"SetPoint same",
|
||||
"bench_n = bench_n + 1; BenchHost:SetPoint('CENTER', 0, 0)",
|
||||
),
|
||||
(
|
||||
"SetText same",
|
||||
"bench_n = bench_n + 1; BenchText:SetText('bench')",
|
||||
),
|
||||
(
|
||||
"SetTexture swap",
|
||||
"bench_n = bench_n + 1; BenchArt:SetTexture(math.mod(bench_n, 2) == 0 and \
|
||||
'Interface\\\\Buttons\\\\UI-CheckBox-Check' or \
|
||||
'Interface\\\\Buttons\\\\UI-CheckBox-Up')",
|
||||
),
|
||||
(
|
||||
"SetWidth",
|
||||
"bench_n = bench_n + 1; BenchHost:SetWidth(200 + math.mod(bench_n, 2))",
|
||||
),
|
||||
(
|
||||
"CreateTexture",
|
||||
"bench_n = bench_n + 1; local t = BenchHost:CreateTexture(nil, 'ARTWORK'); \
|
||||
t:SetWidth(4); t:SetHeight(4); t:SetPoint('TOPLEFT', BenchHost, 'TOPLEFT', \
|
||||
math.mod(bench_n, 50), 0)",
|
||||
),
|
||||
(
|
||||
"CreateFrame",
|
||||
"bench_n = bench_n + 1; local f = CreateFrame('Frame', nil, BenchHost); \
|
||||
f:SetWidth(4); f:SetHeight(4); f:SetPoint('TOPLEFT', BenchHost, 'TOPLEFT', \
|
||||
math.mod(bench_n, 50), 0)",
|
||||
),
|
||||
(
|
||||
"SetBackdrop",
|
||||
"bench_n = bench_n + 1; BenchHost:SetBackdrop(bench_spec)",
|
||||
),
|
||||
];
|
||||
const N: u32 = 200;
|
||||
for (name, lua) in cases {
|
||||
let (d0, s0, r0) = (s.layout_derivations(), s.layout_solves(), s.layout_rounds());
|
||||
let mut widest = (0, 0);
|
||||
let t = Instant::now();
|
||||
for _ in 0..N {
|
||||
s.run(lua).unwrap();
|
||||
app_frame(&mut s);
|
||||
std::hint::black_box(s.extract());
|
||||
let (f, r) = s.layout_last_scope();
|
||||
widest = (widest.0.max(f), widest.1.max(r));
|
||||
}
|
||||
println!(
|
||||
"STRUCT {name:<15} {:.3} ms/iter derivations/iter={:.2} solves/iter={:.2} \
|
||||
rounds/iter={:.2} widest scope={widest:?}",
|
||||
ms(t) / f64::from(N),
|
||||
(s.layout_derivations() - d0) as f64 / f64::from(N),
|
||||
(s.layout_solves() - s0) as f64 / f64::from(N),
|
||||
(s.layout_rounds() - r0) as f64 / f64::from(N),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
//! The server alert (`SHOW_SERVER_ALERT`): the one background fetch of the text the login screen
|
||||
//! shows over its boxes, and the verb that opens a link in it.
|
||||
//!
|
||||
//! The glue manager's init (`0x46a400`) reads the Lua global `SERVER_ALERT_URL` by name (`0x46a541`
|
||||
//! → `0x703bf0`, after the GlueXML load at `0x46a505`, so `GlueLocalization` overrides the stock
|
||||
//! `GlueStrings` value), sets `alertPending` and clears `alertReady` and the buffer
|
||||
//! (`0xb41e28`, `0xb41e2c`, `0xb414f8`), and starts the download once (`0x46a564` → `0x7a6ee0`):
|
||||
//! `GET`, agent `Blizzard Web Client` (`0x87d3b0`), a 5000 ms connect and receive timeout
|
||||
//! (`0x7a6ee6`), redirects followed. Only a 2xx status reads the body (`0x7a6a0e`-`0x7a6a21`); each
|
||||
//! read of up to 0x4000 bytes (`0x7a6a56`) reaches the callback (`0x46a580`) with its size, and the
|
||||
//! callback appends it to the 0x800-byte buffer ([`AlertBuffer`]). When the connection handle
|
||||
//! closes (`[esi+4]` at `0x7a6be3`, the `InternetConnect` handle of `0x7a6ddf`), the callback runs
|
||||
//! once more with the done flag (`0x7a6c4b`), and a buffer that begins `SERVERALERT:` case-folded
|
||||
//! (`0x836ed8`) sets `alertReady` (`0x46a5f1`). Every failure is silent, and nothing refetches.
|
||||
//!
|
||||
//! An `https://` URL shows nothing in the reference either: `InternetConnect` asks for the HTTP
|
||||
//! service (`0x7a6dcf`) and `HttpOpenRequest` passes `0x84000200` (`0x7a6e34`), without
|
||||
//! `INTERNET_FLAG_SECURE`, so it speaks plain HTTP to the URL's port (443 for `https://`) and gets
|
||||
//! no 2xx. benilla does not dial an `https://` URL, with the same result, and logs one line. The
|
||||
//! client sets neither redirect flag (`0x4000`, `0x8000`); what WinINet does with an
|
||||
//! http-to-https redirect is the OS's, not in the client's bytes, and benilla does not follow one.
|
||||
//!
|
||||
//! Deviation: the body is read with 0x4000-byte reads filled to the end of the body, so what a
|
||||
//! read returns does not depend on how the network cut the packets; and the reading stops once
|
||||
//! the buffer is full, since no later byte can change it.
|
||||
//! Deviation: the connection is direct. The reference opens its session with access type 0
|
||||
//! (`InternetOpenA`, `0x7a6d97`), the system's proxy settings, which benilla does not read.
|
||||
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// What `0x46b957` skips to find the text: the prefix `0x836ed8` names (`0x836b3c` points at it).
|
||||
const PREFIX: &[u8] = b"SERVERALERT:";
|
||||
/// The buffer `0xb414f8` and the stack copy in the callback, both 0x800 bytes with their NUL.
|
||||
const BUFFER: usize = 0x800;
|
||||
/// One read (`0x7a6a56`).
|
||||
const CHUNK: usize = 0x4000;
|
||||
/// The user agent (`0x87d3b0`).
|
||||
const AGENT: &str = "Blizzard Web Client";
|
||||
/// Redirects followed before the fetch gives up.
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
/// A status line or header line past this is not an HTTP response.
|
||||
const MAX_LINE: usize = 8192;
|
||||
const MAX_HEADERS: usize = 100;
|
||||
|
||||
/// The connect and receive timeouts, `0x1388` ms each (`0x7a6ee6`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Timeouts {
|
||||
pub connect: Duration,
|
||||
pub read: Duration,
|
||||
}
|
||||
|
||||
impl Timeouts {
|
||||
pub(super) const REFERENCE: Timeouts = Timeouts {
|
||||
connect: Duration::from_millis(5000),
|
||||
read: Duration::from_millis(5000),
|
||||
};
|
||||
}
|
||||
|
||||
/// The callback's buffer (`0xb414f8`): what the chunks of a response add up to.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct AlertBuffer {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl AlertBuffer {
|
||||
/// One read's bytes (`0x46a580`). An empty read adds nothing. Otherwise the callback copies the
|
||||
/// chunk into a 0x800-byte stack string with `SStrCopy` (`0x64a5a0`, size `min(len, 0x800)`),
|
||||
/// which stops at a NUL and leaves room for its own, so the chunk's last byte is lost; then it
|
||||
/// appends that to the buffer with `SStrPack` (`0x64a760`, size 0x800), so chunks add up to
|
||||
/// 0x7ff bytes and no more.
|
||||
pub(super) fn push(&mut self, chunk: &[u8]) {
|
||||
if chunk.is_empty() {
|
||||
return;
|
||||
}
|
||||
let piece = &chunk[..chunk.len().min(BUFFER) - 1];
|
||||
let piece = &piece[..piece.iter().position(|&b| b == 0).unwrap_or(piece.len())];
|
||||
let room = (BUFFER - 1).saturating_sub(self.buf.len());
|
||||
self.buf.extend_from_slice(&piece[..piece.len().min(room)]);
|
||||
}
|
||||
|
||||
/// Nothing a later chunk adds can change the buffer.
|
||||
pub(super) fn is_full(&self) -> bool {
|
||||
self.buf.len() >= BUFFER - 1
|
||||
}
|
||||
|
||||
/// The done call (`0x46a5c0`): the text after the prefix when the buffer begins with it, any
|
||||
/// case (`SStrCmpI` over the prefix's length, `0x46a5e8`); a shorter or different buffer is no
|
||||
/// alert.
|
||||
pub(super) fn finish(self) -> Option<Vec<u8>> {
|
||||
let head = self.buf.get(..PREFIX.len())?;
|
||||
head.eq_ignore_ascii_case(PREFIX)
|
||||
.then(|| self.buf[PREFIX.len()..].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a download ended without its body.
|
||||
#[derive(Debug)]
|
||||
pub(super) enum FetchError {
|
||||
/// Not an `http://` or `https://` URL, or one with no host.
|
||||
BadUrl,
|
||||
/// An `https://` URL, the deviation above.
|
||||
Https,
|
||||
/// A status that is neither 2xx nor a redirect with a `Location`.
|
||||
Status(u16),
|
||||
TooManyRedirects,
|
||||
/// Not an HTTP response.
|
||||
Protocol,
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FetchError::BadUrl => f.write_str("not an http URL"),
|
||||
FetchError::Https => f.write_str("https"),
|
||||
FetchError::Status(status) => write!(f, "status {status}"),
|
||||
FetchError::TooManyRedirects => f.write_str("too many redirects"),
|
||||
FetchError::Protocol => f.write_str("not an HTTP response"),
|
||||
FetchError::Io(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for FetchError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
FetchError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `http://` URL.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Url {
|
||||
host: String,
|
||||
port: u16,
|
||||
/// The request target: the path and query, `/` at least.
|
||||
target: String,
|
||||
}
|
||||
|
||||
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
s.get(..prefix.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(prefix))
|
||||
.map(|_| &s[prefix.len()..])
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<Url, FetchError> {
|
||||
let rest = match strip_prefix_ci(url, "http://") {
|
||||
Some(rest) => rest,
|
||||
None if strip_prefix_ci(url, "https://").is_some() => return Err(FetchError::Https),
|
||||
None => return Err(FetchError::BadUrl),
|
||||
};
|
||||
let rest = rest.split('#').next().unwrap_or_default();
|
||||
let end = rest.find(['/', '?']).unwrap_or(rest.len());
|
||||
let (authority, tail) = rest.split_at(end);
|
||||
// User information is not sent: the request names the host alone.
|
||||
let authority = authority.rsplit('@').next().unwrap_or_default();
|
||||
let (host, port) = match authority.strip_prefix('[') {
|
||||
Some(v6) => {
|
||||
let (host, after) = v6.split_once(']').ok_or(FetchError::BadUrl)?;
|
||||
(host, after.strip_prefix(':'))
|
||||
}
|
||||
None => match authority.rsplit_once(':') {
|
||||
Some((host, port)) => (host, Some(port)),
|
||||
None => (authority, None),
|
||||
},
|
||||
};
|
||||
let port = match port {
|
||||
None | Some("") => 80,
|
||||
Some(p) => p.parse().map_err(|_| FetchError::BadUrl)?,
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(FetchError::BadUrl);
|
||||
}
|
||||
let target = if tail.is_empty() || tail.starts_with('?') {
|
||||
format!("/{tail}")
|
||||
} else {
|
||||
tail.to_string()
|
||||
};
|
||||
Ok(Url {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
target,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a `Location` points from `base`: a full URL, a scheme-less `//host/..`, an absolute path
|
||||
/// or a path relative to the request's directory.
|
||||
fn resolve(base: &Url, location: &str) -> Result<Url, FetchError> {
|
||||
let location = location.trim();
|
||||
if strip_prefix_ci(location, "http://").is_some()
|
||||
|| strip_prefix_ci(location, "https://").is_some()
|
||||
{
|
||||
return parse_url(location);
|
||||
}
|
||||
if let Some(rest) = location.strip_prefix("//") {
|
||||
return parse_url(&format!("http://{rest}"));
|
||||
}
|
||||
let target = if location.starts_with('/') {
|
||||
location.to_string()
|
||||
} else {
|
||||
let path = base.target.split('?').next().unwrap_or("/");
|
||||
let dir = &path[..path.rfind('/').map_or(0, |i| i + 1)];
|
||||
format!("{dir}{location}")
|
||||
};
|
||||
Ok(Url {
|
||||
host: base.host.clone(),
|
||||
port: base.port,
|
||||
target,
|
||||
})
|
||||
}
|
||||
|
||||
fn connect(url: &Url, timeouts: Timeouts) -> Result<TcpStream, FetchError> {
|
||||
let mut last = None;
|
||||
for addr in (url.host.as_str(), url.port).to_socket_addrs()? {
|
||||
match TcpStream::connect_timeout(&addr, timeouts.connect) {
|
||||
Ok(stream) => {
|
||||
stream.set_read_timeout(Some(timeouts.read))?;
|
||||
stream.set_write_timeout(Some(timeouts.read))?;
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => last = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last
|
||||
.unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no address"))
|
||||
.into())
|
||||
}
|
||||
|
||||
/// One line without its terminator, `None` at the end of the stream.
|
||||
fn read_line(r: &mut impl BufRead) -> Result<Option<String>, FetchError> {
|
||||
let mut line = Vec::new();
|
||||
r.by_ref()
|
||||
.take(MAX_LINE as u64)
|
||||
.read_until(b'\n', &mut line)?;
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if line.last() != Some(&b'\n') {
|
||||
return Err(FetchError::Protocol);
|
||||
}
|
||||
while matches!(line.last(), Some(b'\n' | b'\r')) {
|
||||
line.pop();
|
||||
}
|
||||
Ok(Some(String::from_utf8_lossy(&line).into_owned()))
|
||||
}
|
||||
|
||||
/// A response head: the status and the headers the body needs.
|
||||
#[derive(Debug, Default)]
|
||||
struct Head {
|
||||
status: u16,
|
||||
location: Option<String>,
|
||||
content_length: Option<u64>,
|
||||
chunked: bool,
|
||||
}
|
||||
|
||||
fn read_head(r: &mut impl BufRead) -> Result<Head, FetchError> {
|
||||
let status = read_line(r)?.ok_or(FetchError::Protocol)?;
|
||||
let mut parts = status.split_whitespace();
|
||||
if !parts.next().is_some_and(|v| v.starts_with("HTTP/")) {
|
||||
return Err(FetchError::Protocol);
|
||||
}
|
||||
let mut head = Head {
|
||||
status: parts
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or(FetchError::Protocol)?,
|
||||
..Head::default()
|
||||
};
|
||||
for _ in 0..MAX_HEADERS {
|
||||
let line = read_line(r)?.ok_or(FetchError::Protocol)?;
|
||||
if line.is_empty() {
|
||||
return Ok(head);
|
||||
}
|
||||
let Some((name, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"location" => head.location = Some(value.to_string()),
|
||||
"content-length" => head.content_length = value.parse().ok(),
|
||||
"transfer-encoding" => {
|
||||
head.chunked = value
|
||||
.split(',')
|
||||
.any(|t| t.trim().eq_ignore_ascii_case("chunked"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(FetchError::Protocol)
|
||||
}
|
||||
|
||||
/// `Transfer-Encoding: chunked`, decoded.
|
||||
struct ChunkedReader<R> {
|
||||
inner: R,
|
||||
/// Bytes left in the current chunk.
|
||||
left: u64,
|
||||
/// The previous chunk's trailing CRLF is still to be read.
|
||||
crlf: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<R: BufRead> ChunkedReader<R> {
|
||||
fn new(inner: R) -> Self {
|
||||
ChunkedReader {
|
||||
inner,
|
||||
left: 0,
|
||||
crlf: false,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_chunk(&mut self) -> io::Result<()> {
|
||||
let bad = |what| io::Error::new(io::ErrorKind::InvalidData, what);
|
||||
if self.crlf {
|
||||
read_line(&mut self.inner).map_err(|_| bad("chunk end"))?;
|
||||
self.crlf = false;
|
||||
}
|
||||
let line = read_line(&mut self.inner)
|
||||
.map_err(|_| bad("chunk size"))?
|
||||
.ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?;
|
||||
let size = line.split(';').next().unwrap_or_default().trim();
|
||||
self.left = u64::from_str_radix(size, 16).map_err(|_| bad("chunk size"))?;
|
||||
if self.left == 0 {
|
||||
// The trailers, up to the blank line.
|
||||
let mut ended = false;
|
||||
for _ in 0..MAX_HEADERS {
|
||||
if read_line(&mut self.inner)
|
||||
.map_err(|_| bad("trailer"))?
|
||||
.is_none_or(|l| l.is_empty())
|
||||
{
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !ended {
|
||||
return Err(bad("trailer"));
|
||||
}
|
||||
self.done = true;
|
||||
} else {
|
||||
self.crlf = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: BufRead> Read for ChunkedReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
while self.left == 0 {
|
||||
if self.done {
|
||||
return Ok(0);
|
||||
}
|
||||
self.next_chunk()?;
|
||||
}
|
||||
let want = buf
|
||||
.len()
|
||||
.min(usize::try_from(self.left).unwrap_or(usize::MAX));
|
||||
let n = self.inner.read(&mut buf[..want])?;
|
||||
if n == 0 {
|
||||
return Err(io::ErrorKind::UnexpectedEof.into());
|
||||
}
|
||||
self.left -= n as u64;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read until `buf` is full or the body ends. A read error after some bytes returns them with the
|
||||
/// error.
|
||||
fn fill(body: &mut impl Read, buf: &mut [u8]) -> (usize, Option<io::Error>) {
|
||||
let mut n = 0;
|
||||
while n < buf.len() {
|
||||
match body.read(&mut buf[n..]) {
|
||||
Ok(0) => break,
|
||||
Ok(k) => n += k,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return (n, Some(e)),
|
||||
}
|
||||
}
|
||||
(n, None)
|
||||
}
|
||||
|
||||
/// The body in reads of up to [`CHUNK`] bytes, each handed to `on_chunk`, which answers whether to
|
||||
/// read on.
|
||||
fn stream_body(
|
||||
body: &mut impl Read,
|
||||
on_chunk: &mut dyn FnMut(&[u8]) -> bool,
|
||||
) -> Result<(), FetchError> {
|
||||
let mut chunk = vec![0u8; CHUNK];
|
||||
loop {
|
||||
let (n, error) = fill(body, &mut chunk);
|
||||
if n > 0 && !on_chunk(&chunk[..n]) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(e) = error {
|
||||
return Err(e.into());
|
||||
}
|
||||
if n < CHUNK {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The request, in one write so the head leaves in one segment. An IPv6 literal is bracketed in
|
||||
/// the Host header.
|
||||
fn send_request(w: &mut impl Write, url: &Url) -> io::Result<()> {
|
||||
let name = if url.host.contains(':') {
|
||||
format!("[{}]", url.host)
|
||||
} else {
|
||||
url.host.clone()
|
||||
};
|
||||
let host = if url.port == 80 {
|
||||
name
|
||||
} else {
|
||||
format!("{name}:{}", url.port)
|
||||
};
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {AGENT}\r\nAccept: */*\r\n\
|
||||
Cache-Control: no-cache\r\nPragma: no-cache\r\nConnection: close\r\n\r\n",
|
||||
url.target
|
||||
);
|
||||
w.write_all(request.as_bytes())
|
||||
}
|
||||
|
||||
/// `GET url`, following up to [`MAX_REDIRECTS`] redirects, and the 2xx body to `on_chunk` in reads
|
||||
/// of up to 0x4000 bytes. A body that ends in an error has handed over what came before it.
|
||||
pub(super) fn download(
|
||||
url: &str,
|
||||
timeouts: Timeouts,
|
||||
on_chunk: &mut dyn FnMut(&[u8]) -> bool,
|
||||
) -> Result<(), FetchError> {
|
||||
let mut url = parse_url(url)?;
|
||||
for _ in 0..=MAX_REDIRECTS {
|
||||
let mut stream = connect(&url, timeouts)?;
|
||||
send_request(&mut stream, &url)?;
|
||||
let mut reader = BufReader::with_capacity(CHUNK, stream);
|
||||
let head = read_head(&mut reader)?;
|
||||
match head.status {
|
||||
// `0x7a6a11`-`0x7a6a21`: 200 up to 299 reads the body.
|
||||
200..=299 => {
|
||||
if head.status == 204 {
|
||||
return Ok(());
|
||||
}
|
||||
return if head.chunked {
|
||||
stream_body(&mut ChunkedReader::new(reader), on_chunk)
|
||||
} else if let Some(length) = head.content_length {
|
||||
stream_body(&mut reader.take(length), on_chunk)
|
||||
} else {
|
||||
stream_body(&mut reader, on_chunk)
|
||||
};
|
||||
}
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
let location = head.location.ok_or(FetchError::Status(head.status))?;
|
||||
url = resolve(&url, &location)?;
|
||||
}
|
||||
status => return Err(FetchError::Status(status)),
|
||||
}
|
||||
}
|
||||
Err(FetchError::TooManyRedirects)
|
||||
}
|
||||
|
||||
/// The alert `url` serves: the text after `SERVERALERT:`, or `None` for every other outcome.
|
||||
pub(super) fn fetch_alert(url: &str, timeouts: Timeouts) -> Option<Vec<u8>> {
|
||||
let mut buffer = AlertBuffer::default();
|
||||
let result = download(url, timeouts, &mut |chunk| {
|
||||
buffer.push(chunk);
|
||||
!buffer.is_full()
|
||||
});
|
||||
match result {
|
||||
Err(FetchError::Https) => {
|
||||
info!("server alert: {url} is https; the reference shows nothing for it, nor does benilla");
|
||||
}
|
||||
Err(e) => debug!("server alert: {url}: {e}"),
|
||||
Ok(()) => {}
|
||||
}
|
||||
buffer.finish()
|
||||
}
|
||||
|
||||
/// The fetch on a thread of its own: the channel holds the alert once, or closes without one.
|
||||
pub(super) fn spawn_fetch(url: String) -> crossbeam_channel::Receiver<Option<Vec<u8>>> {
|
||||
let (tx, rx) = crossbeam_channel::bounded(1);
|
||||
let started = std::thread::Builder::new()
|
||||
.name("server-alert".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(fetch_alert(&url, Timeouts::REFERENCE));
|
||||
});
|
||||
if let Err(e) = started {
|
||||
debug!("server alert: no thread: {e}");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// Whether this run may touch the network or the desktop for the alert: not a test, and not a
|
||||
/// capture or any other unattended run.
|
||||
pub(super) fn allowed() -> bool {
|
||||
!cfg!(test) && !crate::run_mode::unattended()
|
||||
}
|
||||
|
||||
/// Whether the first glue VM of the process has been seen: the reference initialises its glue
|
||||
/// manager once (`0x46a400`), so the fetch happens once however many glue VMs follow.
|
||||
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// The fetch for the glue manager's init (`0x46a564`): started once per process, and only for a
|
||||
/// URL that is not empty, when the run [`allowed`] it.
|
||||
pub(super) fn begin_once(url: &str) -> Option<crossbeam_channel::Receiver<Option<Vec<u8>>>> {
|
||||
if STARTED.swap(true, std::sync::atomic::Ordering::SeqCst) || !allowed() || url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(spawn_fetch(url.to_string()))
|
||||
}
|
||||
|
||||
/// `LaunchURL` reaching the host (`0x5abd00`): a URL that passed the verb's whitelist.
|
||||
#[derive(Message, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LaunchUrl(pub(crate) String);
|
||||
|
||||
/// Open each launched URL in the player's browser (`0x429ff0`'s `ShellExecute "open"`), never in
|
||||
/// a test or an unattended run.
|
||||
pub(crate) fn open_launched(mut urls: MessageReader<LaunchUrl>) {
|
||||
for LaunchUrl(url) in urls.read() {
|
||||
// The verb gated it, and the opener does not take the message's word for it.
|
||||
if !benilla_ui::script::launch_url_allowed(url) {
|
||||
warn!("LaunchURL: {url:?} refused");
|
||||
continue;
|
||||
}
|
||||
if !allowed() {
|
||||
debug!("LaunchURL: {url} not opened in this run");
|
||||
continue;
|
||||
}
|
||||
open_in_browser(url);
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform's opener, detached. The URL is `http://` and letters, digits, `.`, `-` and `/`
|
||||
/// ([`benilla_ui::script::GlueRequest::LaunchUrl`]), so it carries nothing a shell would read.
|
||||
fn open_in_browser(url: &str) {
|
||||
let mut command = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("/usr/bin/open")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// By its full path, never a search of the working directory or the PATH.
|
||||
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
|
||||
let mut c = std::process::Command::new(
|
||||
std::path::Path::new(&root)
|
||||
.join("System32")
|
||||
.join("rundll32.exe"),
|
||||
);
|
||||
c.arg("url.dll,FileProtocolHandler");
|
||||
c
|
||||
} else {
|
||||
std::process::Command::new("xdg-open")
|
||||
};
|
||||
command
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
match command.spawn() {
|
||||
Ok(mut child) => {
|
||||
// Reaped off the main thread, so the opener never lingers as a zombie.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
Err(e) => warn!("LaunchURL: no browser opener: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "server_alert_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,419 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Short enough that the timeout test is quick.
|
||||
const FAST: Timeouts = Timeouts {
|
||||
connect: Duration::from_millis(500),
|
||||
read: Duration::from_millis(300),
|
||||
};
|
||||
|
||||
// ── The buffer, chunk by chunk ──
|
||||
|
||||
#[test]
|
||||
fn a_chunk_loses_its_last_byte_and_stops_at_a_nul() {
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(b"SERVERALERT:abX");
|
||||
assert_eq!(b.buf, b"SERVERALERT:ab");
|
||||
// One byte leaves nothing (`SStrCopy` of size 1), and an empty read adds nothing.
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(b"X");
|
||||
b.push(b"");
|
||||
assert!(b.buf.is_empty());
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(b"ab\0cdX");
|
||||
assert_eq!(b.buf, b"ab");
|
||||
}
|
||||
|
||||
/// The callback appends (`SStrPack`), so a body read in pieces is the pieces joined, each short
|
||||
/// of its last byte.
|
||||
#[test]
|
||||
fn chunks_add_up_to_the_buffer_and_stop_at_its_cap() {
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(b"SERVERALERT:abX");
|
||||
b.push(b"cdY");
|
||||
assert_eq!(b.finish().as_deref(), Some(&b"abcd"[..]));
|
||||
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(b"SERVERALERT:");
|
||||
b.push(&vec![b'a'; 0x4000]);
|
||||
assert_eq!(b.buf.len(), 0x7ff, "0x800 with the terminator");
|
||||
assert!(b.is_full());
|
||||
b.push(b"more");
|
||||
assert_eq!(b.buf.len(), 0x7ff);
|
||||
// A chunk of exactly 0x7ff or 0x800 bytes fills it the same way.
|
||||
for len in [0x7ff, 0x800, 0x801] {
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(&vec![b'a'; len]);
|
||||
assert_eq!(b.buf.len(), (len.min(0x800) - 1), "{len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prefix_is_case_folded_and_must_be_all_there() {
|
||||
let alert = |body: &[u8]| {
|
||||
let mut b = AlertBuffer::default();
|
||||
b.push(body);
|
||||
b.finish()
|
||||
};
|
||||
assert_eq!(alert(b"serverAlert:hi\n"), Some(b"hi".to_vec()));
|
||||
assert_eq!(alert(b"SERVERALERT:\n"), Some(Vec::new()));
|
||||
assert_eq!(alert(b"SERVERALERT\n"), None, "no colon");
|
||||
assert_eq!(alert(b"SERVERALERT"), None, "a prefix cut short");
|
||||
assert_eq!(alert(b" SERVERALERT:hi\n"), None, "not at the start");
|
||||
assert_eq!(alert(b"<html>SERVERALERT:</html>"), None);
|
||||
assert_eq!(alert(b""), None);
|
||||
}
|
||||
|
||||
// ── The URL ──
|
||||
|
||||
#[test]
|
||||
fn urls_split_into_host_port_and_target() {
|
||||
let url = |s| parse_url(s).unwrap();
|
||||
assert_eq!(
|
||||
url("http://launcher.worldofwarcraft.com/alert"),
|
||||
Url {
|
||||
host: "launcher.worldofwarcraft.com".into(),
|
||||
port: 80,
|
||||
target: "/alert".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(url("HTTP://h:8080").target, "/");
|
||||
assert_eq!(url("http://h:8080?x=1#frag").target, "/?x=1");
|
||||
assert_eq!(url("http://h:8080?x=1").port, 8080);
|
||||
assert_eq!(url("http://u:p@h/a/b?c#d").host, "h");
|
||||
let v6 = url("http://[::1]:81/p");
|
||||
assert_eq!((v6.host.as_str(), v6.port), ("::1", 81));
|
||||
assert!(matches!(parse_url("https://h/"), Err(FetchError::Https)));
|
||||
assert!(matches!(parse_url("HTTPS://h/"), Err(FetchError::Https)));
|
||||
for bad in [
|
||||
"",
|
||||
"ftp://h/",
|
||||
"h/alert",
|
||||
"http://",
|
||||
"http://:80/",
|
||||
"http://h:x/",
|
||||
] {
|
||||
assert!(matches!(parse_url(bad), Err(FetchError::BadUrl)), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_resolves_against_the_request() {
|
||||
let base = parse_url("http://h:81/a/b?q").unwrap();
|
||||
let go = |l| resolve(&base, l).unwrap();
|
||||
assert_eq!(go("/c").target, "/c");
|
||||
assert_eq!(go("c").target, "/a/c");
|
||||
assert_eq!(go("http://other/x").host, "other");
|
||||
assert_eq!(go("//other:9/x").port, 9);
|
||||
assert_eq!((go("/c").host.as_str(), go("/c").port), ("h", 81));
|
||||
assert!(matches!(
|
||||
resolve(&base, "https://h/"),
|
||||
Err(FetchError::Https)
|
||||
));
|
||||
}
|
||||
|
||||
// ── The fetch, against a fake server ──
|
||||
|
||||
/// What a fake server does with a connection.
|
||||
enum Reply {
|
||||
/// Write these bytes and close.
|
||||
Raw(Vec<u8>),
|
||||
/// Accept and say nothing.
|
||||
Silent,
|
||||
/// Write these bytes and then say nothing, the connection open.
|
||||
Stall(Vec<u8>),
|
||||
}
|
||||
|
||||
/// A server on a loopback port: each connection's request head goes to `handler` with the
|
||||
/// connection's number, and the heads are kept.
|
||||
struct Fake {
|
||||
port: u16,
|
||||
heads: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn start(handler: impl Fn(&str, usize, u16) -> Reply + Send + 'static) -> Fake {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let heads = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = heads.clone();
|
||||
std::thread::spawn(move || {
|
||||
for (n, conn) in listener.incoming().enumerate() {
|
||||
let Ok(mut conn) = conn else { return };
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
if conn.read(&mut byte).unwrap_or(0) == 0 {
|
||||
break;
|
||||
}
|
||||
head.push(byte[0]);
|
||||
}
|
||||
let head = String::from_utf8_lossy(&head).into_owned();
|
||||
let reply = handler(&head, n, port);
|
||||
seen.lock().unwrap().push(head);
|
||||
match reply {
|
||||
Reply::Raw(bytes) => {
|
||||
let _ = conn.write_all(&bytes);
|
||||
}
|
||||
Reply::Silent => std::thread::sleep(Duration::from_secs(2)),
|
||||
Reply::Stall(bytes) => {
|
||||
let _ = conn.write_all(&bytes);
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Fake { port, heads }
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("http://127.0.0.1:{}{path}", self.port)
|
||||
}
|
||||
|
||||
fn requests(&self) -> usize {
|
||||
self.heads.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(body: &[u8]) -> Reply {
|
||||
let mut raw = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes();
|
||||
raw.extend_from_slice(body);
|
||||
Reply::Raw(raw)
|
||||
}
|
||||
|
||||
fn alert(url: &str) -> Option<String> {
|
||||
fetch_alert(url, FAST).map(|t| String::from_utf8_lossy(&t).into_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_200_with_the_prefix_is_an_alert_and_the_request_is_the_references() {
|
||||
let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:\nRealms are down.\n"));
|
||||
assert_eq!(
|
||||
alert(&fake.url("/alert?x=1")).as_deref(),
|
||||
// The last byte, the final newline, is the callback's to lose.
|
||||
Some("\nRealms are down.")
|
||||
);
|
||||
let head = fake.heads.lock().unwrap()[0].clone();
|
||||
assert!(head.starts_with("GET /alert?x=1 HTTP/1.1\r\n"), "{head}");
|
||||
assert!(head.contains(&format!("Host: 127.0.0.1:{}\r\n", fake.port)));
|
||||
assert!(head.contains("User-Agent: Blizzard Web Client\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_200_without_the_prefix_is_no_alert() {
|
||||
let fake = Fake::start(|_, _, _| ok(b"<html>Not found, really.</html>\n"));
|
||||
assert_eq!(alert(&fake.url("/")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_is_no_alert_even_with_the_prefix_in_its_body() {
|
||||
let body = b"SERVERALERT:\nnot for you\n";
|
||||
let fake = Fake::start(move |_, _, _| {
|
||||
let mut raw = format!(
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes();
|
||||
raw.extend_from_slice(body);
|
||||
Reply::Raw(raw)
|
||||
});
|
||||
assert_eq!(alert(&fake.url("/")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirects_are_followed_up_to_the_limit() {
|
||||
let fake = Fake::start(|head, _, port| {
|
||||
let to = |loc: String| {
|
||||
Reply::Raw(
|
||||
format!("HTTP/1.1 302 Found\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n")
|
||||
.into_bytes(),
|
||||
)
|
||||
};
|
||||
if head.starts_with("GET /a ") {
|
||||
to("/b".into())
|
||||
} else if head.starts_with("GET /b ") {
|
||||
to(format!("http://127.0.0.1:{port}/c"))
|
||||
} else if head.starts_with("GET /loop ") {
|
||||
to("/loop".into())
|
||||
} else {
|
||||
ok(b"SERVERALERT:moved\n")
|
||||
}
|
||||
});
|
||||
assert_eq!(alert(&fake.url("/a")).as_deref(), Some("moved"));
|
||||
assert_eq!(fake.requests(), 3);
|
||||
let before = fake.requests();
|
||||
assert_eq!(alert(&fake.url("/loop")), None);
|
||||
assert_eq!(fake.requests() - before, MAX_REDIRECTS + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redirect_to_https_is_not_followed() {
|
||||
let fake = Fake::start(|_, _, _| {
|
||||
Reply::Raw(
|
||||
b"HTTP/1.1 301 Moved\r\nLocation: https://127.0.0.1:1/alert\r\nContent-Length: 0\r\n\r\n"
|
||||
.to_vec(),
|
||||
)
|
||||
});
|
||||
assert!(matches!(
|
||||
download(&fake.url("/"), FAST, &mut |_| true),
|
||||
Err(FetchError::Https)
|
||||
));
|
||||
assert_eq!(alert(&fake.url("/")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunked_body_is_decoded() {
|
||||
let fake = Fake::start(|_, _, _| {
|
||||
Reply::Raw(
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\
|
||||
7\r\nSERVERA\r\n8;ext=1\r\nLERT:hi!\r\n3\r\n\nyo\r\n0\r\nTrailer: x\r\n\r\n"
|
||||
.to_vec(),
|
||||
)
|
||||
});
|
||||
// One read of the whole body: the decoded bytes, short of the last.
|
||||
assert_eq!(alert(&fake.url("/")).as_deref(), Some("hi!\ny"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_with_no_length_is_read_to_the_close() {
|
||||
let fake = Fake::start(|_, _, _| {
|
||||
Reply::Raw(b"HTTP/1.0 200 OK\r\nServer: x\r\n\r\nSERVERALERT:to the end\n".to_vec())
|
||||
});
|
||||
assert_eq!(alert(&fake.url("/")).as_deref(), Some("to the end"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_says_nothing_times_out() {
|
||||
let fake = Fake::start(|_, _, _| Reply::Silent);
|
||||
let start = Instant::now();
|
||||
assert_eq!(alert(&fake.url("/")), None);
|
||||
assert!(start.elapsed() < Duration::from_millis(1500));
|
||||
}
|
||||
|
||||
/// The status and the first bytes, then silence past the read timeout: the done call
|
||||
/// (`0x7a6c4b`) runs whatever ended the reads, and finds the prefix.
|
||||
#[test]
|
||||
fn a_body_that_stalls_keeps_what_came_before_it() {
|
||||
let fake = Fake::start(|_, _, _| {
|
||||
Reply::Stall(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nSERVERALERT:partial\nmore".to_vec(),
|
||||
)
|
||||
});
|
||||
let start = Instant::now();
|
||||
assert_eq!(alert(&fake.url("/")).as_deref(), Some("partial\nmor"));
|
||||
assert!(start.elapsed() < Duration::from_millis(1500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3_kb_body_fills_the_buffer_and_no_more() {
|
||||
let mut body = b"SERVERALERT:".to_vec();
|
||||
body.extend((0..3000).map(|i| b'a' + (i % 26) as u8));
|
||||
let fake = Fake::start(move |_, _, _| ok(&body.clone()));
|
||||
let text = fetch_alert(&fake.url("/"), FAST).unwrap();
|
||||
assert_eq!(text.len(), 0x7ff - PREFIX.len());
|
||||
assert!(text.starts_with(b"abcdefghij"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_https_url_is_never_dialled() {
|
||||
let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:x"));
|
||||
let https = fake.url("/").replacen("http://", "https://", 1);
|
||||
assert!(matches!(
|
||||
download(&https, FAST, &mut |_| true),
|
||||
Err(FetchError::Https)
|
||||
));
|
||||
assert_eq!(alert(&https), None);
|
||||
assert_eq!(fake.requests(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_connection_is_silent() {
|
||||
let port = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
assert_eq!(alert(&format!("http://127.0.0.1:{port}/")), None);
|
||||
assert!(TcpStream::connect(("127.0.0.1", port)).is_err());
|
||||
}
|
||||
|
||||
/// The fetch runs on a thread of its own, so the caller never waits on it.
|
||||
#[test]
|
||||
fn the_background_fetch_hands_over_its_alert_through_a_channel() {
|
||||
let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:\nhello\n"));
|
||||
let rx = spawn_fetch(fake.url("/"));
|
||||
let got = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(got.as_deref(), Some(&b"\nhello"[..]));
|
||||
}
|
||||
|
||||
/// A test run starts no fetch and opens no browser, whatever the URL.
|
||||
#[test]
|
||||
fn a_test_run_neither_fetches_nor_opens_a_browser() {
|
||||
assert!(!allowed());
|
||||
assert!(begin_once("http://127.0.0.1:1/").is_none());
|
||||
}
|
||||
|
||||
/// A writer that records each call, to see how the request leaves.
|
||||
#[derive(Default)]
|
||||
struct Calls(Vec<Vec<u8>>);
|
||||
|
||||
impl Write for Calls {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.0.push(buf.to_vec());
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_request_head_leaves_in_one_write() {
|
||||
let mut calls = Calls::default();
|
||||
send_request(&mut calls, &parse_url("http://h:81/a?b").unwrap()).unwrap();
|
||||
assert_eq!(calls.0.len(), 1);
|
||||
let head = String::from_utf8(calls.0.remove(0)).unwrap();
|
||||
assert!(
|
||||
head.starts_with("GET /a?b HTTP/1.1\r\nHost: h:81\r\n"),
|
||||
"{head}"
|
||||
);
|
||||
assert!(head.ends_with("\r\n\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ipv6_host_is_bracketed_in_the_host_header() {
|
||||
let mut calls = Calls::default();
|
||||
send_request(&mut calls, &parse_url("http://[::1]/").unwrap()).unwrap();
|
||||
let head = String::from_utf8(calls.0.concat()).unwrap();
|
||||
assert!(head.contains("\r\nHost: [::1]\r\n"), "{head}");
|
||||
calls.0.clear();
|
||||
send_request(&mut calls, &parse_url("http://[::1]:8080/").unwrap()).unwrap();
|
||||
assert!(String::from_utf8(calls.0.concat())
|
||||
.unwrap()
|
||||
.contains("\r\nHost: [::1]:8080\r\n"));
|
||||
}
|
||||
|
||||
/// A chunked body whose trailers never end is refused, not read forever.
|
||||
#[test]
|
||||
fn endless_chunked_trailers_are_refused() {
|
||||
let mut raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n".to_vec();
|
||||
for _ in 0..MAX_HEADERS + 5 {
|
||||
raw.extend_from_slice(b"X-Trailer: y\r\n");
|
||||
}
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
let fake = Fake::start(move |_, _, _| Reply::Raw(raw.clone()));
|
||||
assert!(matches!(
|
||||
download(&fake.url("/"), FAST, &mut |_| true),
|
||||
Err(FetchError::Io(_))
|
||||
));
|
||||
// The ordinary trailer still ends it.
|
||||
let fake = Fake::start(|_, _, _| {
|
||||
Reply::Raw(
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX: y\r\n\r\n".to_vec(),
|
||||
)
|
||||
});
|
||||
assert!(download(&fake.url("/"), FAST, &mut |_| true).is_ok());
|
||||
}
|
||||
@@ -178,6 +178,9 @@ mod tests {
|
||||
("cursor.rs", "last_ptr"),
|
||||
// The wheel's fraction toward its next notch, a fact about the device, not the VM.
|
||||
("bindings.rs", "notches"),
|
||||
// The keys believed down, a fact about the keyboard; an alt-tab wipes it by reconcile.
|
||||
("bindings.rs", "down"),
|
||||
("ui_script/input.rs", "pressed"),
|
||||
];
|
||||
|
||||
use crate::test_support::{fn_parameter_lists, rust_files};
|
||||
|
||||
@@ -2,14 +2,44 @@
|
||||
//! registers, against what this client answers.
|
||||
//!
|
||||
//! A 1.12 predicate answers `1` or `nil`, never a boolean (`IsPetAttackActive` aside), so kinds
|
||||
//! are gated with arity. The table says per row what to trust: `arity_conf = exact`, kinds only
|
||||
//! where `kinds_conf = agree`, never `argc_conf`. Only query verbs are probed, a call that raises
|
||||
//! is skipped, and a name registered from several tables (`GetBuildInfo` is 5 in the glue table
|
||||
//! `0x8373b8`, 3 in-game at `0x83de68`) is read at its non-glue row.
|
||||
//! are gated with arity. The table says per row what to trust: `arity_conf = exact` or `set` (a
|
||||
//! call is right when its count is a member, so `GetContainerItemInfo`'s `0|5` is measured),
|
||||
//! kinds only where `kinds_conf = agree`, never `argc_conf`. Every global is probed except those
|
||||
//! that end the session, each on every rung of [`ARG_LADDER`] (an indexed getter raises with none
|
||||
//! and answers its miss tuple for `0`, `99` or `-1`); a rung that raises is skipped. A name
|
||||
//! registered from several tables (`GetBuildInfo` is 5 in the glue table `0x8373b8`, 3 in-game at
|
||||
//! `0x83de68`) is read at its non-glue row. The boolean gate also adds each row whose kinds are
|
||||
//! `(nil) | (number)` to the query verbs.
|
||||
|
||||
/// Globals known to answer the wrong arity: a name, the reference's arity and the reason. The
|
||||
/// list may only shrink.
|
||||
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
|
||||
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[
|
||||
(
|
||||
"ClearCursor",
|
||||
1,
|
||||
"Deviation: the handler ends `mov eax,1` with nothing pushed (0x4895bc), handing back the \
|
||||
stack top; copying a stack-top leak would copy a bug",
|
||||
),
|
||||
(
|
||||
"TaxiNodeSetCurrent",
|
||||
1,
|
||||
"Deviation: the same stack-top return (0x4dcd7c)",
|
||||
),
|
||||
];
|
||||
|
||||
/// The query verbs the negative-index rule reads: a verb that acts on its index (`PickupAction`)
|
||||
/// raises on a negative in this client, and the reference's verbs fall out of range quietly too,
|
||||
/// but those are a separate sweep.
|
||||
const QUERY_PREFIXES: &[&str] = &["Get", "Is", "Has", "Can", "Unit", "Num"];
|
||||
|
||||
/// Getters whose negative index raises in the reference too, each with the bytes that say so: a
|
||||
/// slot reader with a whitelist raises outside it, so `-1` raises where `99` answers. Verified
|
||||
/// entries only.
|
||||
const NEGATIVE_RAISES: &[(&str, &str)] = &[(
|
||||
"IsInventoryItemLocked",
|
||||
"its slot reader raises \"Invalid inventory slot\" outside {0, 1-23, 40-69, 82-113} (0x4c8520, \
|
||||
0x4c8e7c): 99 is the keyring's slot 98 and answers, -1 raises",
|
||||
)];
|
||||
|
||||
/// The kind gate's list, same rules: a name, the wrong kinds it answers and the reason. An entry
|
||||
/// whose binding comes into agreement fails the gate.
|
||||
@@ -28,7 +58,9 @@ const GLUE_TABLE: &str = "0x8373b8";
|
||||
struct Row {
|
||||
name: String,
|
||||
table_va: String,
|
||||
arity: usize,
|
||||
/// The return counts the reference can push: one for an `exact` row, the set for a `set` row
|
||||
/// (`0|5`: a miss and a hit that differ). A call is right when its count is a member.
|
||||
arities: Vec<usize>,
|
||||
/// The `kinds` column verbatim, `|`-separated alternatives; empty unless trustworthy.
|
||||
kinds: String,
|
||||
}
|
||||
@@ -37,7 +69,7 @@ fn rows() -> Vec<Row> {
|
||||
rows_of_kind("global")
|
||||
}
|
||||
|
||||
/// The `arity_conf = exact` rows of one `table_kind`: `global` is the registrar surface,
|
||||
/// The `arity_conf = exact` or `set` rows of one `table_kind`: `global` is the registrar surface,
|
||||
/// `baselib` the 36-entry Lua base array looped into `_G` at `0x811e28`.
|
||||
fn rows_of_kind(kind: &str) -> Vec<Row> {
|
||||
let tsv = concat!(
|
||||
@@ -50,7 +82,11 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
|
||||
.filter_map(|l| {
|
||||
let f: Vec<&str> = l.split('\t').collect();
|
||||
// name fn pair_va table_va table_kind argc argc_conf arity arity_conf kinds …
|
||||
if f.len() < 9 || f[4] != kind || f[8] != "exact" {
|
||||
if f.len() < 9 || f[4] != kind || !(f[8] == "exact" || f[8] == "set") {
|
||||
return None;
|
||||
}
|
||||
let arities: Vec<usize> = f[7].split('|').filter_map(|n| n.parse().ok()).collect();
|
||||
if arities.is_empty() || (f[8] == "exact" && arities.len() != 1) {
|
||||
return None;
|
||||
}
|
||||
// A `delegated-push` row's callee pushes on its behalf, unseen by the push trace, so
|
||||
@@ -59,7 +95,7 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
|
||||
Some(Row {
|
||||
name: f[0].to_string(),
|
||||
table_va: f[3].to_string(),
|
||||
arity: f[7].parse().ok()?,
|
||||
arities,
|
||||
// Kinds are advisory unless `kinds_conf` is `agree`, so such a row carries none.
|
||||
kinds: if f.len() > 10 && f[10] == "agree" && !delegated {
|
||||
f[9].to_string()
|
||||
@@ -102,10 +138,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
|
||||
|
||||
for (name, rs) in &by_name {
|
||||
// Query verbs only: the gate calls what it measures.
|
||||
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
||||
.iter()
|
||||
.any(|p| name.starts_with(p))
|
||||
{
|
||||
if NOT_PROBED.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
||||
@@ -117,47 +150,70 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
|
||||
let Some(first) = candidates.first() else {
|
||||
continue;
|
||||
};
|
||||
if candidates.iter().any(|r| r.arity != first.arity) {
|
||||
if candidates.iter().any(|r| r.arities != first.arities) {
|
||||
continue;
|
||||
}
|
||||
let want = first.arity;
|
||||
let want = &first.arities;
|
||||
|
||||
// Counted with 5.0's `arg.n` inside the `pcall`: `select` is not a 1.12 global
|
||||
// (`lua50::install` removes it).
|
||||
let call = if name.starts_with("Unit") {
|
||||
format!(r#"{name}("player")"#)
|
||||
} else {
|
||||
format!("{name}()")
|
||||
};
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return -1 end \
|
||||
local ok, n = pcall(function() return (function(...) return arg.n end)({call}) end) \
|
||||
if not ok then return -1 end return n"
|
||||
);
|
||||
let got: i64 = match s.eval(&probe) {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if got < 0 {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if got as usize != want {
|
||||
if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert_eq!(
|
||||
*known, want,
|
||||
"{name} is on the not-yet-asserted list at a stale arity — the table now says \
|
||||
{want}"
|
||||
);
|
||||
// Every rung of the argument ladder that returns is measured: an indexed getter raises
|
||||
// with none and answers its miss tuple for `0` or `99`, a hit leg is not seeded here. A
|
||||
// binding that raises on every rung is skipped. Counted with 5.0's `arg.n` inside the
|
||||
// `pcall`: `select` is not a 1.12 global (`lua50::install` removes it).
|
||||
let mut measured = false;
|
||||
// Which rungs returned: an index that falls out of range answers the miss tuple whatever
|
||||
// its sign, so a getter that answers a positive rung and raises its negative twin reads
|
||||
// its index in a type that refuses a negative (#1016).
|
||||
let mut answered: Vec<&str> = Vec::new();
|
||||
for args in ARG_LADDER {
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return -1 end \
|
||||
local ok, n = pcall(function() return (function(...) return arg.n end)({name}({args})) end) \
|
||||
if not ok then return -1 end return n"
|
||||
);
|
||||
let got: i64 = match s.eval(&probe) {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if got < 0 {
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!("{name}: answers {got}, reference states {want}"));
|
||||
answered.push(args);
|
||||
measured = true;
|
||||
if !want.contains(&(got as usize)) {
|
||||
if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert!(
|
||||
want.contains(known),
|
||||
"{name} is on the not-yet-asserted list at a stale arity — the table now \
|
||||
says {want:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}({args}): answers {got}, reference states {want:?}"
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if measured {
|
||||
checked += 1;
|
||||
}
|
||||
// Only the first argument: a second-argument index (`GetContainerItemInfo(bag, slot)`) or
|
||||
// a spell slot (`GetSpellName`, which raises "Invalid spell slot" for one out of range)
|
||||
// has its own law per binding.
|
||||
if QUERY_PREFIXES.iter().any(|p| name.starts_with(p))
|
||||
&& answered.contains(&"99")
|
||||
&& !answered.contains(&"-1")
|
||||
&& !NEGATIVE_RAISES.iter().any(|(n, _)| n == name)
|
||||
{
|
||||
mismatches.push(format!(
|
||||
"{name}(-1) raises while {name}(99) answers: a negative index must fall out of range"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
|
||||
assert!(
|
||||
checked >= 240,
|
||||
checked >= 900,
|
||||
"the gate measured only {checked} bindings — it has stopped covering anything"
|
||||
);
|
||||
// The list may only shrink: an entry that now answers the reference's arity must come off.
|
||||
@@ -166,12 +222,20 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
|
||||
.map(|(n, ..)| *n)
|
||||
.filter(|n| {
|
||||
by_name.get(n).is_some_and(|rs| {
|
||||
let want = rs[0].arity;
|
||||
s.eval::<i64>(&format!(
|
||||
"local ok, n = pcall(function() return (function(...) return arg.n end)({n}()) end) \
|
||||
if not ok then return -1 end return n"
|
||||
))
|
||||
.is_ok_and(|got| got >= 0 && got as usize == want)
|
||||
let want = &rs[0].arities;
|
||||
// Answers the reference's arity on every rung that returns, and on one at least.
|
||||
let seen: Vec<i64> = ARG_LADDER
|
||||
.iter()
|
||||
.filter_map(|args| {
|
||||
s.eval::<i64>(&format!(
|
||||
"local ok, n = pcall(function() return (function(...) return arg.n end)({n}({args})) end) \
|
||||
if not ok then return -1 end return n"
|
||||
))
|
||||
.ok()
|
||||
.filter(|got| *got >= 0)
|
||||
})
|
||||
.collect();
|
||||
!seen.is_empty() && seen.iter().all(|got| want.contains(&(*got as usize)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -263,14 +327,17 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if got as usize != r.arity {
|
||||
if !r.arities.contains(&(got as usize)) {
|
||||
if let Some((_, known, _)) = BASELIB_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert_eq!(*known, r.arity, "{name} is listed at a stale arity");
|
||||
assert!(
|
||||
r.arities.contains(known),
|
||||
"{name} is listed at a stale arity"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}: answers {got}, reference states {}",
|
||||
r.arity
|
||||
"{name}: answers {got}, reference states {:?}",
|
||||
r.arities
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -281,7 +348,7 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
|
||||
let Ok(kinds) = s.eval::<String>(&format!(
|
||||
"local t = {{ {name}({args}) }} local out = '' \
|
||||
for i = 1, {} do out = out .. (i > 1 and ',' or '') .. type(t[i]) end return out",
|
||||
r.arity
|
||||
r.arities[0]
|
||||
)) else {
|
||||
continue;
|
||||
};
|
||||
@@ -455,6 +522,65 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The eight `GameTooltip` setters that answer the builder's return value (`0x5325bb`, `0x532eb4`
|
||||
/// and kin): one value, `1` or nil, never a boolean and never nothing. `Set*` is outside the
|
||||
/// query-verb prefixes the widget gates probe, so they are named here and called with arguments
|
||||
/// that reach their empty and miss legs on a bare VM.
|
||||
#[test]
|
||||
fn the_tooltip_flag_setters_answer_one_value_that_is_a_number_or_nil() {
|
||||
const SETTERS: &[&str] = &[
|
||||
"SetAction",
|
||||
"SetSpell",
|
||||
"SetShapeshift",
|
||||
"SetPetAction",
|
||||
"SetInboxItem",
|
||||
"SetAuctionItem",
|
||||
"SetAuctionSellItem",
|
||||
"SetSendMailItem",
|
||||
];
|
||||
const TIP_TABLE: &str = "0x854198";
|
||||
let tsv = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../reference/1.12-shapes.tsv"
|
||||
);
|
||||
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
||||
let s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(KIND_HELPER).expect("kind helper");
|
||||
s.run("PGTipSetter = CreateFrame('GameTooltip', 'PGTipSetter', UIParent)")
|
||||
.unwrap();
|
||||
// `Set*(slot)`, `SetSpell(slot, bookType)`, `SetAuctionItem(type, index)`, and the bare ones.
|
||||
const ARGS: &[&str] = &["", "1", "1, 'spell'", "'list', 1", "0", "99", "-1"];
|
||||
for name in SETTERS {
|
||||
let row = text
|
||||
.lines()
|
||||
.map(|l| l.split('\t').collect::<Vec<_>>())
|
||||
.find(|f| f.len() > 9 && f[4] == "widget" && f[3] == TIP_TABLE && f[0] == *name)
|
||||
.unwrap_or_else(|| panic!("{name} has no GameTooltip row"));
|
||||
assert_eq!(
|
||||
(row[7], row[8], row[9]),
|
||||
("1", "exact", "(nil) | (number)"),
|
||||
"{name}: the table row changed under the gate"
|
||||
);
|
||||
let mut measured = 0;
|
||||
for args in ARGS {
|
||||
let probe = format!(
|
||||
"local ok, s = pcall(function() return PGKinds(PGTipSetter:{name}({args})) end) \
|
||||
if not ok then return '?' end return s"
|
||||
);
|
||||
let got = s.eval::<String>(&probe).unwrap();
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
measured += 1;
|
||||
assert!(
|
||||
got == "nil" || got == "number",
|
||||
"{name}({args}) answers ({got}), the reference pushes one nil or number"
|
||||
);
|
||||
}
|
||||
assert!(measured > 0, "{name} raised on every rung");
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a `kinds` cell into alternatives of per-slot kinds: `(nil) | (number)` is two of one
|
||||
/// slot, and `()` yields none, so the kind gates skip a verb that answers nothing.
|
||||
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
|
||||
@@ -515,10 +641,7 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
|
||||
let mut checked = 0usize;
|
||||
let mut mismatches: Vec<String> = Vec::new();
|
||||
for (name, rs) in &by_name {
|
||||
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
||||
.iter()
|
||||
.any(|p| name.starts_with(p))
|
||||
{
|
||||
if NOT_PROBED.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
||||
@@ -537,59 +660,69 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let call = if name.starts_with("Unit") {
|
||||
format!(r#"{name}("player")"#)
|
||||
} else {
|
||||
format!("{name}()")
|
||||
};
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return '?' end \
|
||||
local ok, s = pcall(function() return PGKinds({call}) end) \
|
||||
if not ok then return '?' end return s"
|
||||
);
|
||||
let Ok(got) = s.eval::<String>(&probe) else {
|
||||
continue;
|
||||
};
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
|
||||
// An arity fault is the arity gate's to report.
|
||||
if !want.iter().any(|alt| alt.len() == got.len()) {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
let agrees = want.iter().any(|alt| {
|
||||
alt.len() == got.len()
|
||||
&& alt
|
||||
.iter()
|
||||
.zip(&got)
|
||||
.all(|(w, g)| slot_accepts(w.as_str(), g))
|
||||
});
|
||||
let observed = got.join(",");
|
||||
if !agrees {
|
||||
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
||||
assert_eq!(
|
||||
*known, observed,
|
||||
"{name}'s not-yet-asserted row is stale: it now answers ({observed})"
|
||||
);
|
||||
// Every rung of the argument ladder that returns is compared, so an indexed getter's miss
|
||||
// leg is measured with the rest (see the arity gate).
|
||||
let mut measured = false;
|
||||
let mut disagreed = false;
|
||||
for args in ARG_LADDER {
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return '?' end \
|
||||
local ok, s = pcall(function() return PGKinds({name}({args})) end) \
|
||||
if not ok then return '?' end return s"
|
||||
);
|
||||
let Ok(got) = s.eval::<String>(&probe) else {
|
||||
continue;
|
||||
};
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}: answers ({observed}), reference states {}",
|
||||
first.kinds
|
||||
));
|
||||
} else {
|
||||
assert!(
|
||||
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
|
||||
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
|
||||
);
|
||||
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
|
||||
// An arity fault is the arity gate's to report.
|
||||
if !want.iter().any(|alt| alt.len() == got.len()) {
|
||||
continue;
|
||||
}
|
||||
measured = true;
|
||||
let agrees = want.iter().any(|alt| {
|
||||
alt.len() == got.len()
|
||||
&& alt
|
||||
.iter()
|
||||
.zip(&got)
|
||||
.all(|(w, g)| slot_accepts(w.as_str(), g))
|
||||
});
|
||||
let observed = got.join(",");
|
||||
if !agrees {
|
||||
disagreed = true;
|
||||
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name)
|
||||
{
|
||||
assert_eq!(
|
||||
*known, observed,
|
||||
"{name}'s not-yet-asserted row is stale: it now answers ({observed})"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mismatches.push(format!(
|
||||
"{name}({args}): answers ({observed}), reference states {}",
|
||||
first.kinds
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if measured {
|
||||
checked += 1;
|
||||
// A listed entry on which no rung disagrees has come into agreement; a rung that
|
||||
// disagrees and was listed `continue`s without pushing, which is not agreement.
|
||||
if !disagreed {
|
||||
assert!(
|
||||
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
|
||||
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A floor, not a target.
|
||||
assert!(
|
||||
checked >= 150,
|
||||
checked >= 460,
|
||||
"the kind gate measured only {checked} bindings — it has stopped covering anything"
|
||||
);
|
||||
assert!(
|
||||
@@ -706,21 +839,71 @@ fn every_widget_method_answers_the_reference_s_return_kinds() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Argument tuples the boolean gate tries in order until a call returns. A plausible wrong
|
||||
/// argument can change which kinds come back, which is why the kind gate passes only a unit
|
||||
/// token, but it never turns `1` into `true`.
|
||||
const ARG_LADDER: &[&str] = &["", "\"player\"", "1", "1, 1", "\"player\", 1"];
|
||||
/// Argument tuples the boolean gate tries, every one that returns being measured: a plausible
|
||||
/// wrong argument can change which kinds come back, which is why the kind gate passes only a unit
|
||||
/// token, but it never turns `1` into `true`. The ids and indices reach the rows
|
||||
/// [`seed_boolean_probe_state`] pushes, each leg of a predicate on its own tuple.
|
||||
const ARG_LADDER: &[&str] = &[
|
||||
"",
|
||||
"\"player\"",
|
||||
"0",
|
||||
"99",
|
||||
// A negative index truncates, decrements and compares unsigned, so it falls out of range
|
||||
// (`0x4f6ed8`) where a getter that reads it as `usize` would raise.
|
||||
"-1",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"1, 1",
|
||||
"1, 2",
|
||||
"2, 1",
|
||||
"\"player\", 1",
|
||||
"\"player\", \"player\"",
|
||||
];
|
||||
|
||||
/// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's
|
||||
/// own `rawequal` answer a `boolean`, and every other predicate pushes `1` or `nil`, which
|
||||
/// `x == 1`, `tostring(x)` or a saved variable tell apart from `true`.
|
||||
#[test]
|
||||
fn no_query_binding_answers_a_lua_boolean() {
|
||||
// The reference's own two; the list may only shrink.
|
||||
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
|
||||
/// The global bindings the boolean gate probes: every `global` row of the table, whatever its
|
||||
/// name or `arity_conf`. 1.12 has no boolean-returning global but `IsPetAttackActive`, so a
|
||||
/// `Pickup*` or `Place*` verb that hands back whether it moved something fails here as readily as
|
||||
/// a predicate that answers `true` (`LootSlotIsItem`, `Resurrect*`, `Check*Dist`).
|
||||
fn predicate_names() -> Vec<String> {
|
||||
let tsv = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../reference/1.12-shapes.tsv"
|
||||
);
|
||||
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
||||
let mut names: Vec<String> = text
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
||||
.filter_map(|l| {
|
||||
let f: Vec<&str> = l.split('\t').collect();
|
||||
(f.len() >= 10 && f[4] == "global" && !NOT_PROBED.contains(&f[0]))
|
||||
.then(|| f[0].to_string())
|
||||
})
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
let all = rows();
|
||||
let mut s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
/// Globals the boolean gate leaves alone: they end the session or the VM, not answer a value.
|
||||
const NOT_PROBED: &[&str] = &[
|
||||
"Quit",
|
||||
"ForceQuit",
|
||||
"Logout",
|
||||
"CancelLogout",
|
||||
"ReloadUI",
|
||||
"RestartGx",
|
||||
];
|
||||
|
||||
/// The state the predicates that answer only against state read from: a seated player, two
|
||||
/// talents (an ordinary one met and learnable, an exceptional one neither), two loot rolls (a
|
||||
/// resolved bind-on-pickup one and one still in flight), a respec question in range, a quest log
|
||||
/// with one finished line. Each carries both legs of its flags.
|
||||
fn seed_boolean_probe_state(s: &mut benilla_ui::script::UiScript) {
|
||||
use benilla_ui::script::{
|
||||
LootRollEntry, LootRollsState, QuestLogEntryView, QuestLogObjectiveView, QuestLogState,
|
||||
TalentPrereqView, TalentTabView, TalentUiState, TalentView,
|
||||
};
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
@@ -730,24 +913,101 @@ fn no_query_binding_answers_a_lua_boolean() {
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.set_talents(TalentUiState {
|
||||
tabs: vec![TalentTabView {
|
||||
name: "Probe".into(),
|
||||
background: "Interface\\TalentFrame\\Probe-".into(),
|
||||
points_spent: 0,
|
||||
}],
|
||||
talents: vec![vec![
|
||||
TalentView {
|
||||
name: "Ordinary".into(),
|
||||
tier: 1,
|
||||
column: 1,
|
||||
max_rank: 5,
|
||||
meets_prereq: true,
|
||||
..Default::default()
|
||||
},
|
||||
TalentView {
|
||||
name: "Exceptional".into(),
|
||||
tier: 2,
|
||||
column: 1,
|
||||
max_rank: 1,
|
||||
exceptional: true,
|
||||
prereqs: vec![
|
||||
TalentPrereqView {
|
||||
tier: 1,
|
||||
column: 1,
|
||||
learnable: true,
|
||||
},
|
||||
TalentPrereqView {
|
||||
tier: 1,
|
||||
column: 2,
|
||||
learnable: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
]],
|
||||
points: (1, 0),
|
||||
});
|
||||
s.set_loot_rolls(LootRollsState {
|
||||
rolls: vec![
|
||||
LootRollEntry {
|
||||
roll_id: 1,
|
||||
name: Some("Bound".into()),
|
||||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||||
quality: Some(4),
|
||||
bind_on_pickup: true,
|
||||
item_id: 1,
|
||||
..Default::default()
|
||||
},
|
||||
LootRollEntry {
|
||||
roll_id: 2,
|
||||
item_id: 2,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
});
|
||||
s.set_talent_master_pending(true);
|
||||
s.set_quest_log(QuestLogState {
|
||||
num_quests: 1,
|
||||
entries: vec![QuestLogEntryView {
|
||||
quest_id: 1,
|
||||
title: "Probe".into(),
|
||||
objectives: vec![QuestLogObjectiveView {
|
||||
text: "Probes slain: 1/1".into(),
|
||||
kind: "monster".into(),
|
||||
finished: true,
|
||||
cur: 1,
|
||||
req: 1,
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's
|
||||
/// own `rawequal` answer a `boolean`, and every other predicate pushes `1` or `nil`, which
|
||||
/// `x == 1`, `tostring(x)` or a saved variable tell apart from `true`.
|
||||
#[test]
|
||||
fn no_query_binding_answers_a_lua_boolean() {
|
||||
// The reference's own two; the list may only shrink.
|
||||
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
|
||||
|
||||
let mut s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
seed_boolean_probe_state(&mut s);
|
||||
s.run(KIND_HELPER).expect("kind helper");
|
||||
|
||||
let mut names: Vec<&str> = all
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.filter(|n| {
|
||||
["Get", "Is", "Has", "Can", "Unit", "Num"]
|
||||
.iter()
|
||||
.any(|p| n.starts_with(p))
|
||||
})
|
||||
.filter(|n| !REFERENCE_BOOLEANS.contains(n))
|
||||
let names: Vec<String> = predicate_names()
|
||||
.into_iter()
|
||||
.filter(|n| !REFERENCE_BOOLEANS.contains(&n.as_str()))
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
let mut checked = 0usize;
|
||||
let mut measured = std::collections::BTreeSet::new();
|
||||
let mut booleans: Vec<String> = Vec::new();
|
||||
for name in names {
|
||||
for name in &names {
|
||||
for args in ARG_LADDER {
|
||||
let probe = format!(
|
||||
"if type({name}) ~= 'function' then return '?' end \
|
||||
@@ -760,17 +1020,23 @@ fn no_query_binding_answers_a_lua_boolean() {
|
||||
if got == "?" {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if got.split(',').any(|k| k == "boolean") {
|
||||
measured.insert(name.as_str());
|
||||
// One line per binding: the first rung that answers a boolean.
|
||||
if got.split(',').any(|k| k == "boolean")
|
||||
&& !booleans
|
||||
.last()
|
||||
.is_some_and(|l| l.starts_with(&format!("{name}(")))
|
||||
{
|
||||
booleans.push(format!("{name}({args}) answers ({got})"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
checked >= 300,
|
||||
"the boolean gate measured only {checked} bindings — it has stopped covering anything"
|
||||
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
|
||||
measured.len() >= 500,
|
||||
"the boolean gate measured only {} bindings — it has stopped covering anything",
|
||||
measured.len()
|
||||
);
|
||||
assert!(
|
||||
booleans.is_empty(),
|
||||
@@ -810,8 +1076,17 @@ fn no_widget_method_answers_a_lua_boolean() {
|
||||
}
|
||||
}
|
||||
|
||||
// A widget predicate takes a script or object-type name more often than a number.
|
||||
const WIDGET_ARGS: &[&str] = &["", "\"OnClick\"", "\"Frame\"", "1", "1, 1"];
|
||||
// A widget predicate takes a script or object-type name more often than a number, and
|
||||
// `IsOwned` a frame; `PGFrame` is the probe frame made above.
|
||||
const WIDGET_ARGS: &[&str] = &[
|
||||
"",
|
||||
"\"OnClick\"",
|
||||
"\"Frame\"",
|
||||
"1",
|
||||
"1, 1",
|
||||
"PGFrame",
|
||||
"1, 5",
|
||||
];
|
||||
|
||||
let mut checked = 0usize;
|
||||
let mut booleans: Vec<String> = Vec::new();
|
||||
@@ -824,13 +1099,8 @@ fn no_widget_method_answers_a_lua_boolean() {
|
||||
continue;
|
||||
}
|
||||
let (name, table) = (f[0], f[3]);
|
||||
if !["Get", "Is", "Has", "Can", "Num"]
|
||||
.iter()
|
||||
.any(|p| name.starts_with(p))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// No widget method answers a boolean in the reference, so none is exempt here.
|
||||
// No widget method answers a boolean in the reference, so none is exempt here, whatever
|
||||
// its name: `AtBottom`, `SetAlphaGradient` and `IsOwned` push 1 or nil.
|
||||
let Some(obj) = made.get(table) else { continue };
|
||||
for args in WIDGET_ARGS {
|
||||
let probe = format!(
|
||||
@@ -847,8 +1117,8 @@ fn no_widget_method_answers_a_lua_boolean() {
|
||||
checked += 1;
|
||||
if got.split(',').any(|k| k == "boolean") {
|
||||
booleans.push(format!("{obj}:{name}({args}) answers ({got})"));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1065,7 +1065,13 @@ fn shipped_frame_parents() -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||||
let path = entry.path();
|
||||
if !path.extension().is_some_and(|e| e == "xml") {
|
||||
// The glue file loads over the stock glue screens, not this in-game load; its own test
|
||||
// reads its parent (`glue_mgr`'s realmlist test).
|
||||
if !path.extension().is_some_and(|e| e == "xml")
|
||||
|| path
|
||||
.file_name()
|
||||
.is_some_and(|n| n == super::glue::GLUE_LAYER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
@@ -1099,7 +1105,13 @@ pub(super) fn shipped_frame_names() -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||||
let path = entry.path();
|
||||
if !path.extension().is_some_and(|e| e == "xml") {
|
||||
// The glue file loads over the stock glue screens, not this in-game load; its own test
|
||||
// reads its parent (`glue_mgr`'s realmlist test).
|
||||
if !path.extension().is_some_and(|e| e == "xml")
|
||||
|| path
|
||||
.file_name()
|
||||
.is_some_and(|n| n == super::glue::GLUE_LAYER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
@@ -1924,7 +1936,8 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
|
||||
}
|
||||
|
||||
/// benilla's own interface is one layer, and it does not grow: `layer.toc` names exactly these
|
||||
/// files, all ours (none off the chain), and `assets/ui` holds nothing else but that manifest. The
|
||||
/// files, all ours (none off the chain), and `assets/ui` holds nothing else but that manifest and
|
||||
/// the one glue file ([`super::glue::GLUE_LAYER`]). The
|
||||
/// core loads every stock window the player's `FrameXML.toc` lists; what changes the stock UI goes
|
||||
/// in one of these files.
|
||||
#[test]
|
||||
@@ -1953,9 +1966,13 @@ fn the_layer_does_not_grow() {
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect();
|
||||
found.sort();
|
||||
// Deviation: one glue file beside the in-game layer, `AccountLoginRealmlist.xml`, the
|
||||
// realmlist control on the stock login screen, because benilla is a client for any server and
|
||||
// 1.12.1 has no interface for the realmlist, only `realmlist.wtf`.
|
||||
assert_eq!(super::glue::GLUE_LAYER, "AccountLoginRealmlist.xml");
|
||||
let mut shipped: Vec<String> = LAYER
|
||||
.iter()
|
||||
.chain(&["layer.toc"])
|
||||
.chain(&["layer.toc", super::glue::GLUE_LAYER])
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
shipped.sort();
|
||||
@@ -1987,8 +2004,9 @@ fn the_layer_speaks_the_1_12_dialect() {
|
||||
}
|
||||
}
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||||
let files = super::addons::Addon::layer().toc.files;
|
||||
let mut files = super::addons::Addon::layer().toc.files;
|
||||
assert!(files.len() >= 8, "the layer's toc lists {files:?}");
|
||||
files.push(super::glue::GLUE_LAYER.to_string());
|
||||
let word = |text: &str, w: &str| {
|
||||
text.match_indices(w).any(|(i, _)| {
|
||||
let before = text[..i].chars().next_back();
|
||||
|
||||
@@ -539,8 +539,12 @@ fn the_list_reaches_its_last_row_at_the_bars_end() {
|
||||
.eval::<(f64, f64)>("return SkillListScrollFrameScrollBar:GetMinMaxValues()")
|
||||
.unwrap();
|
||||
assert_eq!(bar_max, f64::from((n as i32 - 12) * 15));
|
||||
// The overflow as geometry: the stored range is not re-measured for a child's resize.
|
||||
let overflow = s
|
||||
.eval::<f64>("return SkillListScrollFrame:GetVerticalScrollRange()")
|
||||
.eval::<f64>(
|
||||
"return SkillListScrollFrameScrollChildFrame:GetHeight() \
|
||||
- SkillListScrollFrame:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overflow, f64::from(n as i32 * 15 - 220));
|
||||
assert!(
|
||||
|
||||
@@ -346,7 +346,10 @@ fn set_checked_uses_blizzard_bool_coercion() {
|
||||
("\"false\"", false),
|
||||
("\"1\"", true),
|
||||
("\"0\"", false),
|
||||
("\"junk\"", false),
|
||||
// An unknown first byte takes the default, 1 (`0x6f1c81`-`0x6f1cdc`).
|
||||
("\"junk\"", true),
|
||||
("\"off\"", false),
|
||||
("", true),
|
||||
] {
|
||||
let got = s
|
||||
.eval::<bool>(&format!(
|
||||
|
||||
@@ -819,8 +819,7 @@ pub(crate) fn seat_chain_addon(s: &mut UiScript, name: &str) {
|
||||
let toc = super::reference_ui::read(&format!("Interface/AddOns/{name}/{name}.toc"))
|
||||
.map(|b| benilla_ui::toc::Toc::parse(&benilla_ui::source::decode(&b)))
|
||||
.unwrap_or_else(|| panic!("{name}: no toc off the chain"));
|
||||
let mut info = super::addons::info_from_toc(name, &toc);
|
||||
info.chain = true;
|
||||
let info = super::addons::info_from_toc(name, &toc, true);
|
||||
s.set_addon_chain_reader(Box::new(super::reference_ui::read));
|
||||
s.register_addons(vec![info], None, None, None);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ fn harness(extra: &[&str]) -> UiScript {
|
||||
fn wolf() -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
guid: 0xF130_0000_4500_0001,
|
||||
name: Some("Timber Wolf".into()),
|
||||
health: 30,
|
||||
max_health: 50,
|
||||
@@ -232,6 +233,8 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
// Another unit than `player`, so `UnitIsUnit("target", "player")` is false.
|
||||
guid: 0x0000_0000_0000_0C0C,
|
||||
is_player: true,
|
||||
player_controlled: true,
|
||||
name: Some("Someone".into()),
|
||||
@@ -472,7 +475,7 @@ fn buff_hover_hangs_below_left_of_the_button() {
|
||||
fn a_cursor_seated_gameobject_plate_is_owned() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness(&[]);
|
||||
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
|
||||
assert!(s.world_tooltip_gameobject("Brill", &[], true));
|
||||
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
|
||||
assert!(
|
||||
owned,
|
||||
@@ -491,7 +494,7 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
|
||||
// Questie's hook, in one line: the plate's own show event calls Show() again.
|
||||
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
|
||||
.unwrap();
|
||||
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
|
||||
assert!(s.world_tooltip_gameobject("Brill", &[], true));
|
||||
let shown: bool = s
|
||||
.eval("return GameTooltip:IsShown() and true or false")
|
||||
.unwrap();
|
||||
@@ -510,7 +513,7 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
|
||||
fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness(&[]);
|
||||
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
|
||||
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], false));
|
||||
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
|
||||
assert!(owned, "the corner plate is owned; errors: {:?}", s.errors());
|
||||
let shown: bool = s
|
||||
@@ -526,7 +529,7 @@ fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() {
|
||||
let mut s = harness(&[]);
|
||||
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
|
||||
.unwrap();
|
||||
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
|
||||
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], false));
|
||||
let shown: bool = s
|
||||
.eval("return GameTooltip:IsShown() and true or false")
|
||||
.unwrap();
|
||||
|
||||
@@ -119,6 +119,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
count: 1,
|
||||
quality: Some(3),
|
||||
inventory_type: 0,
|
||||
item_id: 2000,
|
||||
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
|
||||
locked: false,
|
||||
|
||||
@@ -41,8 +41,7 @@ fn answer_measures(s: &mut UiScript) {
|
||||
s.set_measured_text_unwrapped(&answers);
|
||||
}
|
||||
|
||||
/// Every texture path drawn: `<Texture>` regions and the `<Backdrop>` plate, which is its own
|
||||
/// quad kind (`QuadContent::Backdrop`).
|
||||
/// Every texture path drawn: `<Texture>` regions, the `<Backdrop>` plate's nine pieces among them.
|
||||
fn drawn_textures(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
answer_measures(s);
|
||||
@@ -51,7 +50,6 @@ fn drawn_textures(s: &mut UiScript) -> Vec<String> {
|
||||
.into_iter()
|
||||
.filter_map(|q| match q.content {
|
||||
benilla_ui::script::QuadContent::Texture { path: Some(p), .. } => Some(p),
|
||||
benilla_ui::script::QuadContent::Backdrop { path, .. } => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -132,7 +132,7 @@ fn service(
|
||||
name: Some(name.into()),
|
||||
subtext: None,
|
||||
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
||||
description: String::new(),
|
||||
description: None,
|
||||
cost,
|
||||
prof_first_rank: false,
|
||||
category,
|
||||
@@ -654,11 +654,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
|
||||
.map(|b| benilla_ui::toc::Toc::parse(&benilla_ui::source::decode(&b)))
|
||||
.expect("the addon's toc off the chain");
|
||||
assert!(toc.load_on_demand(), "the reference ships it LoadOnDemand");
|
||||
let info = || {
|
||||
let mut i = super::addons::info_from_toc("Blizzard_TrainerUI", &toc);
|
||||
i.chain = true;
|
||||
i
|
||||
};
|
||||
let info = || super::addons::info_from_toc("Blizzard_TrainerUI", &toc, true);
|
||||
let saved_dir =
|
||||
std::env::temp_dir().join(format!("benilla-trainer-saved-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&saved_dir);
|
||||
|
||||
@@ -170,6 +170,10 @@ fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
|
||||
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
|
||||
let mut script = UiScript::new().expect("VM");
|
||||
script.set_screen_size(w, h);
|
||||
// Art with an extent, as the app's probe gives it, so a texture sized 0 on an axis (a tab's
|
||||
// middle, sized by its unmeasured label) resolves instead of failing: a failed texture keeps
|
||||
// the quad its last good resolve wrote, which no later scale moves.
|
||||
script.set_texture_size_probe(Box::new(|_| Some((32, 32))));
|
||||
let failures = super::load_default_ui(&script);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
@@ -187,6 +191,11 @@ fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A frame of the session: a hidden frame pending its resolve passes nothing on (`0x76814b`),
|
||||
// and the stock `FCF_OnUpdate`'s `MouseIsOver` getters are what resolve the hidden Combat Log
|
||||
// every frame (`0x7a1980`), which the tabs anchored through it read.
|
||||
script.tick(0.016);
|
||||
script.resolve();
|
||||
let _ = script.take_errors();
|
||||
|
||||
// The old home: a root `1/S` larger, `UIParent` at scale 1.
|
||||
|
||||
@@ -958,6 +958,57 @@ fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A target whose faction template the server changes live recolours its plate: template 35
|
||||
/// (friendly) to 14 (hostile) names no side either way, so only the template's own edge fires
|
||||
/// `UNIT_FACTION`. No edge, or another unit's, leaves the plate as it was.
|
||||
#[test]
|
||||
fn a_live_faction_template_change_recolours_the_target_plate() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
use crate::net::FieldEdges;
|
||||
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
|
||||
|
||||
const MOB: u64 = 0xF130_0000_3796_0001;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_unit_frames(&s);
|
||||
let mob = |reaction: u8| UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid: MOB,
|
||||
name: Some("Pusillin".into()),
|
||||
health: 100,
|
||||
max_health: 100,
|
||||
level: 58,
|
||||
reaction,
|
||||
can_attack: reaction < 4,
|
||||
..UnitState::default()
|
||||
};
|
||||
let is_green = |c: [f32; 4]| c[0].abs() < 1e-6 && (c[1] - 1.0).abs() < 1e-6;
|
||||
let is_red = |c: [f32; 4]| (c[0] - 1.0).abs() < 1e-6 && c[1].abs() < 1e-6;
|
||||
|
||||
let friendly = mob(5);
|
||||
s.set_unit("target", Some(friendly.clone()));
|
||||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||||
let before = plate_color(&mut s);
|
||||
assert!(is_green(before), "template 35 reads green, got {before:?}");
|
||||
|
||||
let hostile = mob(2);
|
||||
s.set_unit("target", Some(hostile.clone()));
|
||||
let fire = |s: &mut UiScript, prev: &UnitState, edges: &[(u64, u16)]| {
|
||||
crate::ui_unit::fire_transitions(s, "target", Some(prev), &hostile, &FieldEdges::of(edges));
|
||||
};
|
||||
fire(&mut s, &friendly, &[]);
|
||||
let stale = plate_color(&mut s);
|
||||
assert!(is_green(stale), "no edge, no repaint, got {stale:?}");
|
||||
fire(&mut s, &hostile, &[(MOB + 1, FIELD_UNIT_FACTIONTEMPLATE)]);
|
||||
let other = plate_color(&mut s);
|
||||
assert!(is_green(other), "another unit's edge, got {other:?}");
|
||||
fire(&mut s, &hostile, &[(MOB, FIELD_UNIT_FACTIONTEMPLATE)]);
|
||||
let after = plate_color(&mut s);
|
||||
assert!(is_red(after), "template 14 reads red, got {after:?}");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn
|
||||
/// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border),
|
||||
/// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it.
|
||||
@@ -1511,6 +1562,8 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
|
||||
|
||||
let alive = |health: u32, power: u32, power_type: u8| UnitState {
|
||||
exists: true,
|
||||
// The tooltip builder finds a unit by its guid (`0x52a021`).
|
||||
guid: 0xF130_0000_0000_0B0D,
|
||||
name: Some("Somebody".into()),
|
||||
health,
|
||||
max_health: 100,
|
||||
|
||||
@@ -1022,8 +1022,8 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
|
||||
// A `/reload` never leaves `InWorld`, so only a saver in the shutdown tail sees every root.
|
||||
|
||||
/// A window the player has placed, made as a drag makes one: `SetUserPlaced` refuses a frame that
|
||||
/// is neither movable nor resizable. Parentless, so it anchors to the screen root (the file's `-`
|
||||
/// target). Both flags: the cache applies position only to a movable frame, size to a resizable.
|
||||
/// is neither movable nor resizable. Both flags: the cache writes `X:`/`Y:` only for a movable
|
||||
/// frame and `W:`/`H:` only for a resizable one.
|
||||
fn place_a_window(world: &mut World) {
|
||||
world
|
||||
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
@@ -1045,12 +1045,7 @@ fn layout_cache(character: &str) -> Option<String> {
|
||||
|
||||
/// What a saved window's row has to say for the player to get it back.
|
||||
fn assert_probe_row(text: &str) {
|
||||
for want in [
|
||||
"Frame: LayoutProbe",
|
||||
"W: 413",
|
||||
"H: 147",
|
||||
"Point: BOTTOMLEFT - BOTTOMLEFT 61 29",
|
||||
] {
|
||||
for want in ["Frame: LayoutProbe", "X: 61", "W: 413", "H: 147"] {
|
||||
assert!(
|
||||
text.contains(want),
|
||||
"the saved row is missing `{want}`:\n{text}"
|
||||
@@ -1128,6 +1123,51 @@ fn each_character_gets_its_own_layout_cache() {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// A file of benilla's earlier anchor form seats the window through the loader, and the next save
|
||||
/// writes it back in the reference's rect form, so a player's windows survive the change.
|
||||
#[test]
|
||||
fn an_anchor_form_file_is_restored_and_rewritten_as_the_rect() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("layout-anchor-form");
|
||||
let mut world = booted_world();
|
||||
world.init_resource::<crate::ui_layout::LayoutFile>();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
let path = crate::local_state::layout_character_path("Realm", "Onehunter")
|
||||
.expect("a layout path in the hermetic state folder");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
"Frame: LayoutProbe\nW: 413\nH: 147\nPoint: BOTTOMLEFT - BOTTOMLEFT 61 29\n",
|
||||
)
|
||||
.unwrap();
|
||||
author_a_window(&mut world);
|
||||
world
|
||||
.run_system_once(crate::ui_layout::load_layout)
|
||||
.expect("the layout loader ran");
|
||||
let (w, h, left, _) = window_geometry(&world);
|
||||
assert_eq!(
|
||||
(w, h, left),
|
||||
(413.0, 147.0, 61.0),
|
||||
"seated from the anchor form"
|
||||
);
|
||||
|
||||
super::end_ui_session(&mut world);
|
||||
let text = layout_cache("Onehunter").expect("the logout wrote the layout cache");
|
||||
assert_probe_row(&text);
|
||||
assert!(
|
||||
!text.contains("Point:"),
|
||||
"rewritten in the rect form:\n{text}"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// The probe window as FrameXML authors it, the fresh tree a relog meets: not user-placed, with the
|
||||
/// authored `movable`/`resizable` pair the cache's apply reads off the live frame.
|
||||
fn author_a_window(world: &mut World) {
|
||||
@@ -1143,14 +1183,14 @@ fn author_a_window(world: &mut World) {
|
||||
.expect("author the probe window");
|
||||
}
|
||||
|
||||
/// The probe window's live geometry, as the player sees it.
|
||||
fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) {
|
||||
/// The probe window's live geometry, as the player sees it: its size, left and top.
|
||||
fn window_geometry(world: &World) -> (f32, f32, f32, f32) {
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.expect("a VM")
|
||||
.eval::<(f32, f32, String, f32, f32)>(
|
||||
"local p, _, _, x, y = LayoutProbe:GetPoint(1) \
|
||||
return LayoutProbe:GetWidth(), LayoutProbe:GetHeight(), p, x, y",
|
||||
.eval::<(f32, f32, f32, f32)>(
|
||||
"return LayoutProbe:GetWidth(), LayoutProbe:GetHeight(), \
|
||||
LayoutProbe:GetLeft(), LayoutProbe:GetTop()",
|
||||
)
|
||||
.expect("read the probe window back")
|
||||
}
|
||||
|
||||
@@ -752,19 +752,15 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
// The row as `benilla-config/layout/<realm>-<character>.txt` carries it: Cartographer's saved
|
||||
// `LookNFeel` offsets and the size it forces.
|
||||
// The row as the reference's `layout-cache.txt` carries it after Cartographer's `LookNFeel`:
|
||||
// the size it forces, centred by its drag.
|
||||
let row = || benilla_ui::script::FrameLayout {
|
||||
name: "WorldMapFrame".into(),
|
||||
width: 1024.0,
|
||||
height: 768.0,
|
||||
points: vec![benilla_ui::script::LayoutPoint {
|
||||
point: "CENTER".into(),
|
||||
relative_to: Some("UIParent".into()),
|
||||
relative_point: "CENTER".into(),
|
||||
x: -0.6369222,
|
||||
y: 53.861614,
|
||||
}],
|
||||
level: None,
|
||||
x: Some(307),
|
||||
y: Some(-128),
|
||||
width: Some(1024),
|
||||
height: Some(768),
|
||||
};
|
||||
|
||||
s.restore_user_placed_layouts([row()]);
|
||||
@@ -813,7 +809,14 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
);
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(saved.len(), 1, "which is what puts the row back: {saved:?}");
|
||||
assert_eq!(saved[0], row(), "unchanged through the round trip");
|
||||
assert_eq!(
|
||||
benilla_ui::script::FrameLayout {
|
||||
level: None,
|
||||
..saved[0].clone()
|
||||
},
|
||||
row(),
|
||||
"unchanged through the round trip"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ fn text_of(s: &UiScript, fontstring: &str) -> String {
|
||||
}
|
||||
|
||||
/// The zone caches the app pushes on an area change: the minimap line is the subzone, else the
|
||||
/// zone; an empty PvP word or faction is nil.
|
||||
/// zone; an empty PvP word is nil, and an answered one carries its faction name, `""` included.
|
||||
fn area(zone: &str, sub: &str, pvp: &str, faction: &str) -> ZoneTexts {
|
||||
let some = |t: &str| (!t.is_empty()).then(|| t.to_string());
|
||||
ZoneTexts {
|
||||
@@ -26,7 +26,7 @@ fn area(zone: &str, sub: &str, pvp: &str, faction: &str) -> ZoneTexts {
|
||||
subzone: sub.into(),
|
||||
minimap: if sub.is_empty() { zone } else { sub }.into(),
|
||||
pvp_type: some(pvp),
|
||||
pvp_faction: some(faction),
|
||||
pvp_faction: (!pvp.is_empty()).then(|| faction.to_string()),
|
||||
is_arena: false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user