grass fades on a plane, model panes render, and the FPS journal ships
- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004) - a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013) - the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010) - the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -621,12 +621,12 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
"OnCursorChanged",
|
||||
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites.
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites. (`OnUpdateModel` and
|
||||
// `OnAnimFinished` left this list with decision 2007: the tick's model pass fires
|
||||
// them — `script::tests::model_clock`.)
|
||||
"OnHorizontalScroll",
|
||||
"OnHyperlinkEnter",
|
||||
"OnMessageScrollChanged",
|
||||
"OnUpdateModel",
|
||||
"OnAnimFinished",
|
||||
"OnInputLanguageChanged",
|
||||
] {
|
||||
let err = s
|
||||
|
||||
@@ -23,6 +23,7 @@ mod keyboard;
|
||||
mod layout_gate;
|
||||
mod measure;
|
||||
mod minimap;
|
||||
mod model_clock;
|
||||
mod modelframe;
|
||||
mod movable;
|
||||
mod object_model;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! **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]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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).
|
||||
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());
|
||||
}
|
||||
|
||||
/// `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
|
||||
);
|
||||
}
|
||||
@@ -337,13 +337,13 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SetSequenceTime` is a scrub INTO the current sequence**, so changing the sequence drops it.
|
||||
///
|
||||
/// The cooldown indicator drives this pair every frame — `SetSequence(n)` then
|
||||
/// `SetSequenceTime(n, ms)` — and carrying a stale scrub across a sequence change would park the
|
||||
/// new animation at a time belonging to the previous one.
|
||||
/// **`SetSequence` and `SetSequenceTime` are one arm** (`0x7121a0`): each interrupts what plays
|
||||
/// and anchors the new sequence's cursor — at 0, or at the caller's `ms` — on the pane's own
|
||||
/// clock. The clock law itself (advance, wrap, completion, the two handlers) is
|
||||
/// `script::tests::model_clock`; this is the binding-level shape, with no file facts known, which
|
||||
/// is the state the reference's queued replay covers.
|
||||
#[test]
|
||||
fn a_sequence_change_drops_the_scrub() {
|
||||
fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -356,30 +356,33 @@ fn a_sequence_change_drops_the_scrub() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1.12 has no `GetSequence`, so the scrub is read off the model the way `simplehtml`'s tests
|
||||
// 1.12 has no `GetSequence`, so the arm is read off the model the way `simplehtml`'s tests
|
||||
// read their blocks — through the arena, because there is no Lua getter to read it through.
|
||||
let scrub = |s: &UiScript| {
|
||||
let armed = |s: &UiScript| {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
let fh = model.arena.lookup("MSeq").expect("MSeq frame");
|
||||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||||
crate::widget::KindState::Model(m) => (m.sequence, m.sequence_time),
|
||||
crate::widget::KindState::Model(m) => {
|
||||
(m.sequence, m.armed.map(|a| (a.anim_id, a.anchor_ms)))
|
||||
}
|
||||
_ => panic!("MSeq is not a Model"),
|
||||
}
|
||||
};
|
||||
assert_eq!(scrub(&s), (0, Some((0, 250))));
|
||||
// The scrub anchors the cursor 250 ms in: `anchor = clock − ms` at clock 0.
|
||||
assert_eq!(armed(&s), (0, Some((0, -250))));
|
||||
|
||||
s.run("MSeq:SetSequence(3)").unwrap();
|
||||
assert_eq!(
|
||||
scrub(&s),
|
||||
(3, None),
|
||||
"a new sequence starts unscrubbed — the old (sequence, ms) pair is not carried across"
|
||||
armed(&s),
|
||||
(3, Some((3, 0))),
|
||||
"a new sequence starts at its own 0 — the old anchor is not carried across"
|
||||
);
|
||||
|
||||
// ...and ClearModel drops the scrub with the content.
|
||||
// ...and ClearModel releases the instance, arm included.
|
||||
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
|
||||
.unwrap();
|
||||
assert_eq!(scrub(&s).1, None);
|
||||
assert_eq!(armed(&s).1, None);
|
||||
}
|
||||
|
||||
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
|
||||
|
||||
Reference in New Issue
Block a user