modelframe: a unit pane paints, so its OnUpdateModel fires

The paint that fires OnUpdateModel is gated on [widget+0x318] != 0 (0x76d24c) —
the pane's loaded model. This read that as "has a file path", and a SetUnit pane
keeps no path at all: its subject resolves per render, app-side, so SetUnit
writes `unit` and nils `path`. Every unit pane's OnUpdateModel was therefore
permanently inert.

Not cosmetic. unitscan's alert model is made visible *only* from inside its own
OnUpdateModel: reset() sets alpha 0 and arms the handler with a ten-paint settle
delay, and the eleventh call is what sets alpha back to 1
(unitscan.lua:268-292). With the handler never firing, the alert drew a
correctly placed, correctly sized, fully transparent model and the horn played
over an empty card. The layout probe that said the model frame was a healthy
200x120 was right; it was just invisible.

A unit token that names nothing still fires nothing, which is the "!= 0" half of
the gate. Nothing in stock FrameXML or pfUI is woken by this: probed, none of
CharacterModelFrame, DressUpModel, TabardModel or PetModelFrame registers an
OnUpdateModel at all, and the stock consumer (Cooldown.lua) is file-backed and
was already firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-17 07:56:20 -06:00
co-authored by Claude Opus 5
parent 75432f7dde
commit 608b4b961b
2 changed files with 67 additions and 4 deletions
@@ -316,6 +316,51 @@ fn on_update_model_fires_per_visible_paint_with_this() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A pane whose subject is a **unit** paints too, so its `OnUpdateModel` fires. It keeps no file
/// path at all — `SetUnit` nils it and the subject resolves per render — and the reference's gate
/// is the loaded model (`[widget+0x318]`), not the path. unitscan's alert is the live consumer:
/// its model is made visible only from inside that handler, so gating on the path alone drew a
/// transparent model behind the horn.
#[test]
fn a_unit_panes_handler_fires_once_its_token_names_a_unit() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
seen = 0
m = CreateFrame("PlayerModel", "U", UIParent)
m:SetScript("OnUpdateModel", function() seen = seen + 1 end)
m:SetUnit("target")
"#,
)
.unwrap();
// No target: the token names nothing, which is the `≠ 0` half of the reference's gate.
s.tick(0.1);
assert_eq!(s.eval::<i64>("return seen").unwrap(), 0);
s.set_unit(
"target",
Some(crate::script::UnitState {
name: Some("Timmy".into()),
..Default::default()
}),
);
s.tick(0.1);
s.tick(0.1);
assert_eq!(
s.eval::<i64>("return seen").unwrap(),
2,
"one per paint, as a file-backed pane gets"
);
// `ClearModel` drops the unit with the path, so the pane stops painting.
s.run("U:ClearModel()").unwrap();
s.tick(0.1);
assert_eq!(s.eval::<i64>("return seen").unwrap(), 2);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `0x719370` enqueues the completion before it tests the loop flag, so a loop's first pass fires
/// `OnAnimFinished`; a pane with no file fails `0x76d24c`'s gate and never paints.
#[test]
+22 -4
View File
@@ -395,9 +395,19 @@ impl UiScript {
.collect();
model.on_update_model_frames = live;
}
// Only visible Model panes with a file set, resident or streaming: the paint that fires
// the handler (`0x76d1bc`) is gated on `[widget+0x318] ≠ 0` (`0x76d24c`). On any other
// kind the script is inert.
// Only visible Model panes with a subject, resident or streaming: the paint that fires
// the handler (`0x76d1bc`) is gated on `[widget+0x318] ≠ 0` (`0x76d24c`), which is the
// pane's **loaded model**, not the file string.
//
// A `SetUnit` pane has one of those too, and keeps no path at all — its subject
// resolves per render, app-side (`modelframe.rs`'s `SetUnit` writes `unit` and nils
// `path`) — so gating on `path` alone left every unit pane's handler permanently
// inert. Not cosmetic: unitscan's alert model is made visible *only* from inside its
// own `OnUpdateModel` (`unitscan.lua:273-284` counts ten paints as a settle delay
// after `reset()` has set alpha 0), so the alert drew a correctly placed, correctly
// sized, fully transparent model and the horn played over an empty card. A unit token
// that names nothing still fires nothing, which is the `≠ 0` half of the gate. On any
// other kind the script is inert.
let frames: Vec<FrameHandle> = model
.on_update_model_frames
.iter()
@@ -405,7 +415,15 @@ impl UiScript {
.filter(|&h| {
model.arena.frame(h).is_some_and(|f| {
f.effective_visible
&& matches!(&f.kind_state, crate::widget::KindState::Model(m) if m.path.is_some())
&& match &f.kind_state {
crate::widget::KindState::Model(m) => {
m.path.is_some()
|| m.unit
.as_deref()
.is_some_and(|t| model.unit(t).is_some())
}
_ => false,
}
})
})
.collect();