Files
benilla-octo/crates/benilla-ui/src/script/tests/engine_updates.rs
T
Sam SungandClaude Opus 5.5 07d8c8d4cf Kind updates run in the frame's walk slot; OnUpdateModel in draw order (#897)
The tooltip fade, a model's animation clock and its OnAnimFinished ran after the whole OnUpdate walk, and OnUpdateModel fired in creation order. A tooltip hidden through its parent also kept its owner and fade.

In the reference, the walk makes one call per frame: its OnUpdate, then its kind's engine update (a tooltip's fade step, a model pane's clock and completion test). OnUpdateModel fires later, from the paint, in draw order. A tooltip's own hide and show run from any caller, its parent's cascade included: the hide un-owns and clears it; the show needs an owner.

The walk now runs a per-kind update in each frame's slot, OnUpdateModel fires per stratum in draw order after the hover re-pick, and show and hide dispatch to a kind's override. CharacterModelBase's override is left for #890.

Tests pin each order and a parent's hide and show.

Fixes #861
Fixes #863
Fixes #864

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:20:26 +07:00

247 lines
8.6 KiB
Rust

//! The engine's own per-kind updates, each run in its frame's slot of the update walk (`0x765650`,
//! `vtable+0x38` at `0x765710`) right after that frame's `OnUpdate`: GameTooltip's fade
//! (`0x530b20`) and a Model pane's clock and completion (`0x76d7f0`). `OnUpdateModel` fires later,
//! from the draw pass (`0x7657d0`), in draw order.
use crate::script::UiScript;
use crate::widget::{ModelFileFacts, SequenceFacts};
use super::common::script;
/// `LOG` joined by spaces, then emptied.
fn take_log(s: &UiScript) -> String {
s.eval::<String>("local l = table.concat(LOG, ' ') LOG = {} return l")
.unwrap()
}
/// `UI-Cooldown-Indicator.m2`'s two clamped 1000 ms sequences.
const COOLDOWN_FILE: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
fn cooldown_facts() -> ModelFileFacts {
ModelFileFacts {
sequences: [(0, 1000), (1, 1000)]
.into_iter()
.map(|(anim_id, duration_ms)| SequenceFacts {
anim_id,
duration_ms,
looping: false,
})
.collect(),
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
}
}
fn lua_path(path: &str) -> String {
path.replace('\\', "\\\\")
}
/// A fading tooltip, owned and with a line, whose `OnHide` logs `hide`.
const FADING_TIP: &str = r#"
LOG = {}
local root = CreateFrame("Frame", "FadeRoot")
local owner = CreateFrame("Button", "FadeOwner", root)
owner:SetPoint("CENTER", 0, 0); owner:SetWidth(10); owner:SetHeight(10)
local tt = CreateFrame("GameTooltip", "FadeTip", root)
tt:SetFrameStrata("HIGH")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:SetText("Fading")
tt:SetScript("OnHide", function() table.insert(LOG, "hide") end)
"#;
/// The fade is the tooltip's own update (`0x530b9c`, after its `OnUpdate` at `0x530b2d`): a frame
/// walked after it reads this frame's alpha, and on the frame the fade ends the hide (`0x530be9`)
/// and its `OnHide` come before that frame's `OnUpdate`.
#[test]
fn a_frame_walked_after_the_tooltip_sees_this_frames_fade() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(FADING_TIP).unwrap();
s.run(
r#"
local w = CreateFrame("Frame", "FadeWatch")
w:SetFrameStrata("TOOLTIP")
w:SetScript("OnUpdate", function()
if FadeTip:IsShown() then
table.insert(LOG, string.format("shown@%.1f", FadeTip:GetAlpha()))
else
table.insert(LOG, "hidden")
end
end)
FadeTip:FadeOut()
"#,
)
.unwrap();
take_log(&s);
s.tick(0.3);
assert_eq!(take_log(&s), "shown@0.4", "this frame's ramp, not the last");
s.tick(0.3);
assert_eq!(
take_log(&s),
"hide hidden",
"the hide lands in the tooltip's slot"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A pane's update (`0x76d7f0`) runs its `OnUpdate`, then advances its clock, whose completion
/// test drains `OnAnimFinished` in the same call (`0x7074d8`, `0x7075af`): in the pane's place,
/// before frames walked after it and before the draw pass fires `OnUpdateModel`.
#[test]
fn on_anim_finished_fires_in_the_panes_walk_slot() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"
LOG = {{}}
local p = CreateFrame("Model", "SlotPane")
p:SetFrameStrata("LOW")
p:SetModel("{}")
p:SetSequence(1)
p:SetScript("OnUpdate", function() table.insert(LOG, "pane") end)
p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end)
p:SetScript("OnUpdateModel", function() table.insert(LOG, "model") end)
local h = CreateFrame("Frame", "SlotHigh")
h:SetFrameStrata("HIGH")
h:SetScript("OnUpdate", function() table.insert(LOG, "high") end)
"#,
lua_path(COOLDOWN_FILE)
))
.unwrap();
take_log(&s);
s.tick(0.6);
assert_eq!(take_log(&s), "pane high model");
s.tick(0.6); // 1200 ms ≥ the 1000 ms sequence
assert_eq!(take_log(&s), "pane finished high model");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// One virtual call runs both halves (`0x76d7fa`, then `0x76d843`): a pane that hides itself in
/// its `OnUpdate` still advances this frame, and a completion its advance reaches still fires.
/// A tooltip shows nothing here: its hide disarms the fade it would step (`0x530002`).
#[test]
fn a_pane_hidden_by_its_own_on_update_still_advances() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"
LOG = {{}}
local p = CreateFrame("Model", "SelfHide")
p:SetModel("{}")
p:SetSequence(1)
p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end)
"#,
lua_path(COOLDOWN_FILE)
))
.unwrap();
s.tick(0.6);
s.run(r#"SelfHide:SetScript("OnUpdate", function() this:Hide() end)"#)
.unwrap();
take_log(&s);
s.tick(0.6);
assert!(!s.frame_visible("SelfHide"));
assert_eq!(super::model_clock::pane(&s, "SelfHide").clock_ms, 1200);
assert_eq!(take_log(&s), "finished");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A zero advance returns before the completion test and the drain (`0x7074b7`): a sequence
/// already past its end fires nothing until a frame advances the clock.
#[test]
fn a_zero_advance_tests_no_completion() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"
LOG = {{}}
local p = CreateFrame("Model", "ZeroDt")
p:SetModel("{}")
p:SetSequenceTime(1, 1000)
p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end)
"#,
lua_path(COOLDOWN_FILE)
))
.unwrap();
take_log(&s);
s.tick(0.0009); // trunc(0.9 ms) = 0
assert_eq!(super::model_clock::pane(&s, "ZeroDt").clock_ms, 0);
assert_eq!(take_log(&s), "", "no advance, no test");
s.tick(0.016);
assert_eq!(take_log(&s), "finished");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Completion is judged against the arm the previous paint left: an `OnUpdateModel` that scrubs
/// the sequence back each paint does not hide the completion the walk already reported.
#[test]
fn completion_is_read_before_the_paint_re_arms() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"
LOG = {{}}
local p = CreateFrame("Model", "Scrub")
p:SetModel("{}")
p:SetSequence(1)
p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end)
"#,
lua_path(COOLDOWN_FILE)
))
.unwrap();
s.tick(0.6);
s.run(r#"Scrub:SetScript("OnUpdateModel", function() this:SetSequenceTime(1, 0) end)"#)
.unwrap();
take_log(&s);
s.tick(0.6); // the arm from tick one ends at 1000 ms; this tick's clock reads 1200
assert_eq!(take_log(&s), "finished");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `OnUpdateModel` fires from the paint (`0x76d1bc`), in the draw pass's order: stratum from 0
/// (`0x7657ff`), level from 0 (`0x7658c0`), a level's frames first linked first (`0x765920`).
#[test]
fn on_update_model_fires_in_draw_order() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"
LOG = {{}}
function Pane(name, tag, strata, level)
local p = CreateFrame("Model", name)
p:SetFrameStrata(strata)
if level then p:SetFrameLevel(level) end
p:SetModel("{}")
p:SetScript("OnUpdateModel", function() table.insert(LOG, tag) end)
return p
end
Pane("PaneDialog", "dialog", "DIALOG")
Pane("PaneLow", "low", "LOW")
Pane("PaneLevel9", "level9", "MEDIUM", 9)
Pane("PaneLevel2", "level2", "MEDIUM", 2)
"#,
lua_path(COOLDOWN_FILE)
))
.unwrap();
take_log(&s);
s.tick(0.016);
assert_eq!(take_log(&s), "low level2 level9 dialog");
// Within a level the latest link draws last: a pane shown again goes after its peers.
s.run(
r#"
Pane("LinkA", "a", "BACKGROUND", 3)
Pane("LinkB", "b", "BACKGROUND", 3)
LinkA:Hide() LinkA:Show()
"#,
)
.unwrap();
take_log(&s);
s.tick(0.016);
assert_eq!(take_log(&s), "b a low level2 level9 dialog");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}