- 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>
567 lines
21 KiB
Rust
567 lines
21 KiB
Rust
//! **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
|
||
);
|
||
}
|