Files
benilla-octo/crates/benilla-ui/src/script/tests/model_clock.rs
T
SamandClaude Fable 5.1 44803419a7 the model pane takes the file's camera, and the who list sorts
- the `<Model>` widget's perspective leg: a pane whose file supplies the camera it selected renders through a camera of its own whose viewport is its own cell of the same tile atlas — the reference's structure, one target, a viewport per widget, depth cleared per widget — with the composite, packer and reaper untouched; the selection is RAW (`0x76cec0` reads the count off `MD20+0x124` and the record at `[model+0x3c4] + idx·0x84 + 0x80`, `cameraLookup` never consulted), so `M2Model::cameras` is the whole table in file order and `benilla-extract m2cam` prints it, reproducing wow-re's numbers to the digit; the camera is two fields because the reference has two — `+0x320` a PENDING index and `+0x31c` the installed one — and while a request is pending the pane paints nothing (`76d5f0`'s draw gate, now the paint list's own filter); an index past the count installs the NULL camera, which IS the orthographic leg every shipped in-game pane takes; the projection is the booths' byte-derived diagonal-FOV matrix at the pane's aspect, and the eye and target ride the model's root transform as `0x718960`'s publish does — so `SetFacing` cancels along with `SetPosition` and `SetModelScale`, a contradiction with a wow-re note that went out as a §5 round and came back agreeing: the up vector `0x7ac640` builds is model-space +Z at roll 0, the very axis the facing turns about, and the note's sentence is scoped to `<PlayerModel>`'s frozen camera; each perspective camera takes a render layer of its own from a pool of eight, the orthographic camera clears the atlas and every perspective one clears only depth; `SetLight` is a typed `CGLight` that folds each intensity into its colour, normalises a direction on write, quantizes to 8 bits, returns before the copy on `SetLight(0, …)` and reads the second intensity at index 8 on a short ambient block; the five fog verbs are built (`ClearFog` is `76f5c5 and [edi+0x3a4],-2`, bit 0 alone), so the `Model` UNBUILT list is empty for the first time since 1134; a pane that arms a light or fog takes a rig from a lazily-grown pool, and the tiles' material twins — never reaped since the cache was written — are; `WOW_PROBE_MODEL_CAMERA=1` reads the renderer's own camera back for the three cancellations, `pass=5 fail=0` (2027)
- the layout cache writes in the shutdown tail: an unlocked chat window's size and position did not survive `/logout` or `/reload` and `benilla-config/layout/` was never created (B353) — the lane was right (1.12.1 persists a chat window through `StartMoving`/`StartSizing` and the engine's userPlaced bit; no `SetChatWindowSaved*` verb exists in the 5875 FrameXML), but `UiLayoutPlugin` hung its flush on `OnExit(InWorld)`, which a `/reload` never leaves, its debounced writer was never registered in `Update` at all, and on the roots where the edge did fire the VM-reading saver raced `end_ui_session`'s VM replacement on the same unordered edge (measured: the placement moves with nothing but registration positions); the reference's UI shutdown `0x490bd0` has an ordered tail — `PLAYER_LOGOUT` (`0x490c2a`) → `layout-cache.txt` (`0x490c79`) → the flat saved file (`0x490c7e`) → the per-addon files → `AddOns.txt` — on the same five roots as SavedVariables, `/reload` included, so `save_now` joins `shutdown_ui_state` in that slot, one call site reached from every root, and its own edges are deleted; the debounce stays as the crash writer and only that, a deliberate divergence, and the chat-look flush next door is pinned `.before(end_ui_session)` (2029); the VM gets its font engine at the LOAD edge: the measurer was seated only from an `Update` system on the first frame the glyph atlas exists, but `ReloadUI()` mints a fresh VM and runs the in-game load in the same exclusive slot, so every `<OnLoad>` and every addon's file scope measured 0 through the whole reload (`LOAD MEASURE ZERO` live, 33.185 after) — `seat_text_measurer` is one construction site called from both edges; the options tabs fit once from `<OnShow>`, the `OptionsTab_FitWidth` OnUpdate poll and its `fitted` latch gone, because `GetStringWidth` answers in the region's own units and this window's 0.78 scale is applied in the window's own `<OnShow>` after every `<OnLoad>` — an OnLoad fit read 73.19 where the settled value is 76.47; the settled widths are unchanged to the last digit (76.467232 / 91.661896) and the one-frame 72/87 flash at every open goes; four comments that still called the text measure a frame late, and seven gossip-suite comments that put `UIPanelScrollFrameTemplate` in our `ScrollTemplates.xml` when it lives in the reference's `UIPanelTemplates.xml`, are corrected (2028)
- the `/who` sort is a seven-key chain, not "order by the clicked column" (B365): the reference keeps seven `{key, dir}` slots at `0xc2817c`, seeded zone → level → class → group → name → race → guild; `SortWho` (`0x5ad890`) promotes the clicked key to the front and flips its direction ONLY when it was already there — Name, Name reverses, Name, Level, Name restores what the first click left — and the comparator `0x5ada00` walks the chain as tie-breakers, most-recently-used first, through an ASCII fold (`A`–`Z` by `+0x20`, not a Unicode one), the three DBC arms comparing the localized name out of `ChrClasses`/`ChrRaces`/`AreaTable` where ours had sorted by raw id, and an unresolved row TIES (its miss jumps to `0x5adbb2`, the chain loop's own `inc esi`); the click fires `WHO_LIST_UPDATE` synchronously through `SignalEvent` at `0x5ad9ed`, so the binding promotes and sorts the snapshot and the handler repaints before the OnClick returns, where ours redrew a tick later — a redraw one click stale reads exactly like a header that does nothing; the chain is process state (initialised once from `0x401666`), so it survives a logout; the rider from the same carve: `SMSG_WHO` has two exits, and with `SetWhoToUI` clear more than three rows go to the frame with nothing printed and three or fewer to chat (`0x5adfcc jl`, `eax = 3`) where we printed every unclaimed answer; and wow-re's §11 round corrected the first build before it landed — the chat lines come out in WIRE order with the total line LAST, because `0x5ae0a1` composes each line inside the parse loop and the `qsort` at `0x5ae0e2` sits past its back-edge (2030)
- two lighting reports, one frame and one bit each: the surfacing hitch (B354) is a one-frame stale atmosphere — `update_time_lighting` is late by construction, waiting on the wire drain, the weather tick and the submersion verdict, and five `Update` systems that read the `WowLighting` it resolves declared no ordering, so the executor handed each of them last frame's value, deterministically and invisibly while the atmosphere drifts a minute at a time; at a submersion crossing it jumps, on the very frame the sky and cloud domes un-hide, so the first dry frame painted `LightParams` 27's underwater sky over a dry world (the clip's blue frame, (58, 137, 173) against the settled (53, 58, 34)) and `tick_clouds`' surfacing full-rebuild ran at the underwater cloud density, 0.0, rebuilding an empty field that crept back over 0.4 s — the fix for the pop-in handing the symptom straight back; `LightingConsumeSet` names the read side of `LightingResolveSet`, tied in one place, and four consumers join it, the FFX haze and warp are ordered after the verdict they had the same hole against, and two tests enforce it — zero ambiguities between the two sets, and a source scan over `benilla-world` that fails any `WowLighting` reader not in the set; `emit_minimap` is left stale knowingly because joining cost a 178th item through the world API wall; `WOW_FORCE_SUB=<frames>` holds the eye verdict submerged and releases it, so the crossing reproduces server-less (2032); the black furniture below a boat's deck (B373): `MeshTag` bits 6..=13 are the exterior payload's ground-shade byte and bits 6..=18 the interior payload's SH-probe slot, and `entity_shade` guarded the overlap by asking whether a part was on the interior classifier's Bake law — one of the TWO populations holding a probe slot; the other is a lit interior MODD prop, which a WMO-display GameObject parents under the net entity so it sails with the deck, putting it inside the light node's descendant walk — so every frame the boat's shade byte was written over each prop's slot, `(slot & 0x1f00) | byte`: a neighbouring prop's light where that row was live, a zeroed row — solid black — where it was not; the guard now asks the payload question (`probe_payload`) at both write sites, the tag's bits and the marker derived from one binding; measured live at Ratchet, 749 of 749 resident interior prop parts renamed before, 0 of 640 after, and both ledger leads refuted at the bytes — the MODD colours are healthy (`floor112` raises even `#010101` to diffuse 112) and the fold is rigid-motion invariant since every 1.12 transport authors zero MOLR lights; a prop's mouseover identity now names its lane, words and probe slot, and `WOW_PICK` prints `model_flags.z` (2031); the sea-crossing probe can now measure the seam a report names — an explicit `WOW_PROBE_DOCK` goes first, boards only once the body stands at the dock, carries a map id, takes only a candidate within boarding distance, and drops onto the deck rather than into the hull, because a taxi node's `z` is the WATERLINE for the sea ferries while their decks stand ~16 yd above it — and the seam re-anchor returns the cycle it moved to or `None`, so a healthy crossing no longer logs the server's 9.7 h uptime as a skew inside a 350 s cycle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:37:40 +07:00

567 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **The model pane's clock, arm, and two handlers** (decision 2007; wow-re
//! `ui/scratch/modelframe-render-law.md` §4).
//!
//! A `<Model>` widget owns a private scene whose clock its own `OnUpdate` advances while the
//! frame is visible; `SetSequence`/`SetSequenceTime` arm a sequence with an anchor the sampler
//! re-reads; `OnUpdateModel` fires at the top of every paint and `OnAnimFinished` from the
//! completion of a clamped sequence. Every one of those is Lua-observable — the shipped cooldown
//! sweep is nothing but those handlers driving `SetSequenceTime` — so the law is tested here
//! against the stock `Cooldown.lua`, transcribed verbatim.
//!
//! The engine parses no M2, so a file's sequences and bounds are **facts** the host hands over
//! ([`ModelFileFacts`]); these tests hand over the facts `benilla-extract m2seq` reads off the
//! shipped files.
use super::common::script;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ModelFileFacts, ModelState, SequenceFacts};
/// A file's facts from `(anim_id, duration_ms, looping)` rows, in file order.
fn facts(rows: &[(u16, u32, bool)]) -> ModelFileFacts {
ModelFileFacts {
sequences: rows
.iter()
.map(|&(anim_id, duration_ms, looping)| SequenceFacts {
anim_id,
duration_ms,
looping,
})
.collect(),
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
}
}
/// `UI-Cooldown-Indicator.m2` (`m2seq`): seq 0 = id 0, 1000 ms, clamp; seq 1 = id 1, 1000 ms,
/// clamp.
const COOLDOWN_FILE: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
fn cooldown_facts() -> ModelFileFacts {
facts(&[(0, 1000, false), (1, 1000, false)])
}
/// `MinimapPing.m2` (`m2seq`): seq 0 = id 127, 1333 ms, clamp; seq 1 = id 0, 833 ms, LOOP; seq
/// 2 = id 1, 333 ms, clamp. `SetSequence(0)` is the looping one.
const PING_FILE: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
fn ping_facts() -> ModelFileFacts {
facts(&[(127, 1333, false), (0, 833, true), (1, 333, false)])
}
/// The pane's scene state, read through the arena (1.12 has no getter for any of it).
pub(super) fn pane(s: &UiScript, name: &str) -> ModelState {
let model = s.lua().app_data_ref::<Model>().expect("model");
let fh = model.arena.lookup(name).expect("pane frame");
match &model.arena.frame(fh).expect("live frame").kind_state {
KindState::Model(m) => m.clone(),
_ => panic!("{name} is not a Model"),
}
}
/// `(anim_id, cursor_ms)` of the armed sequence under the facts the engine holds.
fn play_head(s: &UiScript, name: &str, path: &str) -> Option<(u16, u32)> {
let m = pane(s, name);
let model = s.lua().app_data_ref::<Model>().expect("model");
let facts = model.model_facts.get(&crate::widget::model_key(path))?;
m.play_head(facts).map(|p| (p.anim_id, p.cursor_ms))
}
/// The widget's `OnUpdate` (`0x76d7f0`) advances the scene clock by `trunc(elapsed · 1000)` —
/// only while the frame is visible (the UI pump walks visible frames), so a hidden pane's clock
/// stands still and a re-shown pane resumes where it stopped.
#[test]
fn the_clock_runs_only_while_the_pane_is_shown_and_truncates() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(PING_FILE, ping_facts());
s.run(&format!(
r#"p = CreateFrame("Model", "Ping", UIParent) p:SetModel("{}") p:SetSequence(0)"#,
PING_FILE.replace('\\', "\\\\")
))
.unwrap();
s.tick(0.5);
assert_eq!(pane(&s, "Ping").clock_ms, 500);
s.tick(0.0166); // 16.6 ms → 16, not 17
assert_eq!(pane(&s, "Ping").clock_ms, 516, "trunc, no +0.5 (76d854)");
s.run("Ping:Hide()").unwrap();
s.tick(1.0);
assert_eq!(
pane(&s, "Ping").clock_ms,
516,
"hidden: the clock stands still"
);
s.run("Ping:Show()").unwrap();
s.tick(0.1);
assert_eq!(
pane(&s, "Ping").clock_ms,
616,
"re-shown: resumes where it stopped"
);
// The looping sequence wraps on its 833 ms and never completes.
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 616)));
s.tick(0.5); // clock 1116 → 1116 mod 833
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 283)));
}
/// `SetModel` runs the loader's completion (`0x70ebd0`) — arm **Stand** (id 0 if the file owns
/// it, else `animations[0]`'s id), variation 0 — synchronously when the facts are known, and when
/// they land otherwise; until then the pane is a waiter and the host is asked for the file.
#[test]
fn set_model_seeds_stand_now_or_when_the_facts_land() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// Facts first: the arm is immediate.
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"a = CreateFrame("Model", "Known", UIParent) a:SetModel("{}")"#,
COOLDOWN_FILE.replace('\\', "\\\\")
))
.unwrap();
let m = pane(&s, "Known");
assert!(!m.pending_seed);
assert_eq!(
m.armed.map(|a| a.anim_id),
Some(0),
"Stand, armed at the call"
);
assert_eq!(play_head(&s, "Known", COOLDOWN_FILE), Some((0, 0)));
// Facts unknown: a waiter, and the file goes on the host's list once.
s.run(&format!(
r#"b = CreateFrame("Model", "Waiting", UIParent) b:SetModel("{0}")
c = CreateFrame("Model", "Waiting2", UIParent) c:SetModel("{0}")"#,
PING_FILE.replace('\\', "\\\\")
))
.unwrap();
let m = pane(&s, "Waiting");
assert!(m.pending_seed && m.armed.is_none());
assert_eq!(
s.model_facts_wanted(),
vec!["interface/minimap/ping/minimapping".to_string()],
"one request per file, case-folded, no extension"
);
assert!(s.model_facts_wanted().is_empty(), "drained");
// The facts land: every waiter holding the file arms its Stand at its own clock.
s.tick(0.25);
s.set_model_facts(PING_FILE, ping_facts());
for name in ["Waiting", "Waiting2"] {
let m = pane(&s, name);
assert!(!m.pending_seed);
assert_eq!(
m.armed.map(|a| (a.anim_id, a.anchor_ms)),
Some((0, 250)),
"{name}: Stand (the file owns id 0), anchored at the clock the file landed on"
);
}
// A file that does not own id 0 seeds `animations[0]`'s own id.
s.set_model_facts(
r"Interface\Odd.mdx",
facts(&[(204, 500, true), (166, 500, true)]),
);
s.run(r#"d = CreateFrame("Model", "Odd", UIParent) d:SetModel("Interface\\Odd.mdx")"#)
.unwrap();
assert_eq!(pane(&s, "Odd").armed.map(|a| a.anim_id), Some(204));
}
/// `SetSequence(id)` with an id the file does not own **stops what was playing and arms
/// nothing** — `0x7121a0`'s interrupt runs before its bounds check (§4.2). A queued arm on a
/// file still loading is kept until the facts say otherwise.
#[test]
fn an_unowned_id_stops_the_track_and_arms_nothing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(PING_FILE, ping_facts());
s.run(&format!(
r#"p = CreateFrame("Model", "P", UIParent) p:SetModel("{}") p:SetSequence(0)"#,
PING_FILE.replace('\\', "\\\\")
))
.unwrap();
assert!(pane(&s, "P").armed.is_some());
s.run("P:SetSequence(42)").unwrap();
assert_eq!(pane(&s, "P").sequence, 42, "the raw id is still recorded");
assert!(pane(&s, "P").armed.is_none(), "…but nothing plays");
// Queued on an unloaded file, then refused when the facts land.
s.run(r#"q = CreateFrame("Model", "Q", UIParent) q:SetModel("Interface\\Late.mdx") q:SetSequence(7)"#)
.unwrap();
assert_eq!(
pane(&s, "Q").armed.map(|a| a.anim_id),
Some(7),
"kept for the replay"
);
s.set_model_facts(r"Interface\Late.mdx", facts(&[(0, 100, true)]));
assert!(
pane(&s, "Q").armed.is_none(),
"the seed armed Stand, then the queued arm replayed and named an id the file does not \
own — nothing plays"
);
// The other order of the same replay: a queued arm the file DOES own wins over the seed,
// at its original offset, re-anchored on the clock the file landed on.
s.run(r#"r = CreateFrame("Model", "R", UIParent) r:SetModel("Interface\\Later.mdx") r:SetSequenceTime(5, 40)"#)
.unwrap();
s.tick(0.3);
s.set_model_facts(
r"Interface\Later.mdx",
facts(&[(0, 100, true), (5, 900, false)]),
);
let a = pane(&s, "R").armed.expect("the queued arm replays");
assert_eq!((a.anim_id, a.armed_at_ms, a.anchor_ms), (5, 300, 300 - 40));
}
/// The shipped cooldown, on the two handlers and nothing else — `Cooldown.lua` verbatim:
/// `SetTimer` arms sequence 0 and shows; every paint `OnUpdateModel` scrubs
/// `SetSequenceTime(0, elapsed/duration · 1000)` until done, then flips to sequence 1 at 0; the
/// flash's completion (`OnAnimFinished`, 1000 ms later) hides the frame.
#[test]
fn the_cooldown_machine_runs_on_the_two_handlers() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
// `SetTimer`'s gate is `start > 0`: a session clock still at 0 refuses the timer, as the
// reference's would — so the session is a second old before the button is pressed.
s.tick(1.0);
s.run(&format!(
r#"
-- Cooldown.lua, 1.12.1 (the stock file, transcribed for the test — decision 1602).
function CooldownFrame_SetTimer(this, start, duration, enable)
if ( start > 0 and duration > 0 and enable > 0) then
this.start = start;
this.duration = duration;
this.stopping = 0;
this:SetSequence(0);
this:Show();
else
this:Hide();
end
end
function CooldownFrame_OnUpdateModel()
if ( this.stopping == 0 ) then
local finished = (GetTime() - this.start) / this.duration;
if ( finished < 1.0 ) then
local time = finished * 1000;
this:SetSequenceTime(0, time);
return;
end
this.stopping = 1;
this:SetSequence(1);
this:SetSequenceTime(1, 0);
else
this:AdvanceTime();
end
end
function CooldownFrame_OnAnimFinished()
if ( this.stopping == 1 ) then
this:Hide();
end
end
cd = CreateFrame("Model", "CD", UIParent)
cd:SetModel("{}")
cd:SetScript("OnUpdateModel", CooldownFrame_OnUpdateModel)
cd:SetScript("OnAnimFinished", CooldownFrame_OnAnimFinished)
cd:Hide()
fired = {{}}
cd:SetScript("OnAnimFinished", function() table.insert(fired, "finished") CooldownFrame_OnAnimFinished() end)
CooldownFrame_SetTimer(cd, GetTime(), 2, 1)
"#,
COOLDOWN_FILE.replace('\\', "\\\\")
))
.unwrap();
assert!(s.frame_visible("CD"));
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 0)));
// The sweep: each paint scrubs sequence 0 to the elapsed fraction.
s.tick(0.5);
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 250)));
s.tick(1.0);
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 750)));
// A scrub is an ANCHOR, not a freeze: between two paints the clock runs on from it. The
// cooldown never sees that (it re-scrubs every paint), the ping relies on it.
assert_eq!(pane(&s, "CD").armed.map(|a| a.anchor_ms), Some(1500 - 750));
// Done: the flip to the flash, sequence 1 at 0.
s.tick(0.6);
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 0)));
assert!(s.frame_visible("CD"));
assert_eq!(s.eval::<i64>("return cd.stopping").unwrap(), 1);
// The flash runs its 1000 ms; the completion fires once, and the handler hides the frame.
s.tick(0.5);
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 500)));
assert!(s.eval::<bool>("return table.getn(fired) == 0").unwrap());
s.tick(0.6);
assert_eq!(
s.eval::<i64>("return table.getn(fired)").unwrap(),
1,
"OnAnimFinished on the clamped sequence's natural completion"
);
assert!(
!s.frame_visible("CD"),
"…which the stock handler turns into Hide()"
);
// Hidden: no paint, no second completion, the cursor holds at the end.
s.tick(1.0);
assert_eq!(s.eval::<i64>("return table.getn(fired)").unwrap(), 1);
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 1000)));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `OnUpdateModel` runs with `this` set, once per paint of a **visible** pane, before the
/// completion is read — so a handler that re-arms in the same paint completes nothing.
#[test]
fn on_update_model_fires_per_visible_paint_with_this() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
s.run(&format!(
r#"
seen = {{}}
m = CreateFrame("Model", "M", UIParent)
m:SetModel("{}")
m:SetScript("OnUpdateModel", function() table.insert(seen, this:GetName()) end)
m:SetScript("OnAnimFinished", function() error("must not fire") end)
m:SetSequence(1)
"#,
COOLDOWN_FILE.replace('\\', "\\\\")
))
.unwrap();
s.tick(0.1);
s.tick(0.1);
assert_eq!(
s.eval::<i64>("return table.getn(seen)").unwrap(),
2,
"one per tick"
);
assert_eq!(s.eval::<String>("return seen[1]").unwrap(), "M");
s.run("M:Hide()").unwrap();
s.tick(0.1);
assert_eq!(
s.eval::<i64>("return table.getn(seen)").unwrap(),
2,
"no paint while hidden"
);
// Re-arming every paint keeps the clamped sequence from ever completing.
s.run("M:Show() M:SetScript(\"OnUpdateModel\", function() this:SetSequenceTime(1, 0) end)")
.unwrap();
for _ in 0..30 {
s.tick(0.1);
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A LOOPING sequence fires `OnAnimFinished` once too — at the end of its first pass — and keeps
/// looping (`0x719370` enqueues the completion before it tests the loop flag; wow-re
/// `modelframe-texanim-and-sequence-law.md` Q4). And a pane with no file fires no
/// `OnUpdateModel` at all (`76d24c`'s gate), however visible.
#[test]
fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_model_facts(PING_FILE, ping_facts());
s.run(
r#"
done = 0 painted = 0
p = CreateFrame("Model", "Loop", UIParent)
p:SetScript("OnAnimFinished", function() done = done + 1 end)
p:SetScript("OnUpdateModel", function() painted = painted + 1 end)
"#,
)
.unwrap();
s.tick(0.1);
assert_eq!(
s.eval::<i64>("return painted").unwrap(),
0,
"no file: no paint"
);
s.run(&format!(
r#"p:SetModel("{}") p:SetSequence(0)"#,
PING_FILE.replace('\\', "\\\\")
))
.unwrap();
s.tick(0.5);
assert_eq!(
s.eval::<i64>("return painted").unwrap(),
1,
"a file: one paint per tick"
);
assert_eq!(s.eval::<i64>("return done").unwrap(), 0);
s.tick(0.4); // 900 ms ≥ the 833 ms loop
assert_eq!(
s.eval::<i64>("return done").unwrap(),
1,
"the first pass completes"
);
assert_eq!(
play_head(&s, "Loop", PING_FILE),
Some((0, 67)),
"…and the loop goes on"
);
s.tick(1.0);
assert_eq!(s.eval::<i64>("return done").unwrap(), 1, "once per arm");
s.run("p:SetSequence(0)").unwrap();
s.tick(0.9);
assert_eq!(
s.eval::<i64>("return done").unwrap(),
2,
"a re-arm completes again"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The implicit rect** (decision 2015): a model pane that authored no size takes its file's
/// bounding-box extent in LAYOUT units — `768·√(a²+1)` FrameXML units per unit, `1280` at 4:3
/// — the moment the facts are known; it follows the screen's aspect; an authored size wins.
#[test]
fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
// The map arrow's own box (`MinimapArrow.m2`, render law §2): 0.0262 × 0.0263 units.
let arrow = |s: &mut UiScript| {
s.set_model_facts(
r"Interface\Minimap\MinimapArrow.mdx",
ModelFileFacts {
sequences: vec![SequenceFacts {
anim_id: 0,
duration_ms: 3333,
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
cameras: 0,
},
);
};
s.run(
r#"
a = CreateFrame("Model", "Sized", UIParent)
a:SetPoint("CENTER")
b = CreateFrame("Model", "Authored", UIParent)
b:SetPoint("CENTER") b:SetWidth(50) b:SetHeight(20)
a:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
b:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return Sized:GetWidth()").unwrap(),
0.0,
"no facts yet: no rect"
);
arrow(&mut s);
s.resolve();
let (w, h): (f32, f32) = s
.eval("return Sized:GetWidth(), Sized:GetHeight()")
.unwrap();
assert!(
(w - 0.0262 * 1280.0).abs() < 0.05 && (h - 0.0263 * 1280.0).abs() < 0.05,
"{w}×{h}"
);
let (bw, bh): (f32, f32) = s
.eval("return Authored:GetWidth(), Authored:GetHeight()")
.unwrap();
assert_eq!((bw, bh), (50.0, 20.0), "an authored size is untouched");
assert!(pane(&s, "Sized").implicit_size && !pane(&s, "Authored").implicit_size);
// 16:9 — a layout unit is 768·√((16/9)²+1) = 1566.4 FrameXML units.
s.set_screen_size(1600.0, 900.0);
s.resolve();
let w: f32 = s.eval("return Sized:GetWidth()").unwrap();
assert!((w - 0.0262 * 1566.4).abs() < 0.1, "{w}");
// Authoring a size later ends the implicit rect for good.
s.run("Sized:SetWidth(10)").unwrap();
assert!(!pane(&s, "Sized").implicit_size);
s.set_screen_size(1024.0, 768.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sized:GetWidth()").unwrap(), 10.0);
}
/// `ReplaceIconTexture` is the type-14 texture override on the INSTANCE: stored over a file
/// (queued and replayed while it streams), dropped with no file, released by `SetModel` and
/// `ClearModel`.
#[test]
fn replace_icon_texture_lives_and_dies_with_the_instance() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
card = CreateFrame("Model", "Card", UIParent)
card:ReplaceIconTexture("Interface\\Icons\\INV_Misc_Bag_08")
"#,
)
.unwrap();
assert_eq!(
pane(&s, "Card").icon,
None,
"no instance: dropped, never replayed"
);
s.run(
r#"
card:SetModel("Interface\\ItemAnimations\\ForcedBackpackItem.mdx")
card:ReplaceIconTexture("Interface\\Icons\\INV_Misc_Bag_08")
"#,
)
.unwrap();
assert_eq!(
pane(&s, "Card").icon.as_deref(),
Some(r"Interface\Icons\INV_Misc_Bag_08")
);
s.run(r#"card:SetModel("Interface\\ItemAnimations\\ForcedBackpackItem.mdx")"#)
.unwrap();
assert_eq!(
pane(&s, "Card").icon,
None,
"a fresh instance has no override"
);
s.run(r#"card:ReplaceIconTexture("x") card:ClearModel()"#)
.unwrap();
assert_eq!(pane(&s, "Card").icon, None);
assert!(
s.run("card:ReplaceIconTexture(nil)").is_err(),
"the shape-A usage raise stays"
);
}
/// The XML `scale=` on a model pane is the MODEL's scale (`0x76cac0` → `+0x3a0`), never the
/// frame's — the cooldown template's 0.75 must not shrink the widget's rect.
#[test]
fn the_model_scale_attribute_is_the_models_own() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// The pet bar's shine (`PetActionBarFrame.xml:25`), and a `scale="0"` beside it.
let doc = crate::framexml::parse(
r#"<Ui>
<Frame name="Host"><Size><AbsDimension x="30" y="30"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Frames>
<Model name="ShinePane" file="Interface\Buttons\UI-AutoCastButton.mdx" scale="1.2" hidden="true" setAllPoints="true"/>
<Model name="ZeroPane" file="Interface\Buttons\UI-AutoCastButton.mdx" scale="0" hidden="true" setAllPoints="true"/>
</Frames>
</Frame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert_eq!(
s.eval::<(f64, f64)>("return ShinePane:GetModelScale(), ShinePane:GetScale()")
.unwrap(),
(1.2_f32 as f64, 1.0),
"the attribute lands on SetModelScale; the frame scale is untouched"
);
assert_eq!(
s.eval::<f64>("return ZeroPane:GetModelScale()").unwrap(),
1.0,
"≤ 0 is the reference's raise, not a clamp — the default stands"
);
assert!(
report
.warnings
.iter()
.any(|w| w.contains("Invalid model scale")),
"{:?}",
report.warnings
);
}