From 608b4b961b0ca56a751daaf4b2b50d08c528c958 Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 17 Oct 2026 07:56:20 -0600 Subject: [PATCH] modelframe: a unit pane paints, so its OnUpdateModel fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../src/script/tests/model_clock.rs | 45 +++++++++++++++++++ crates/benilla-ui/src/script/tick.rs | 26 +++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/benilla-ui/src/script/tests/model_clock.rs b/crates/benilla-ui/src/script/tests/model_clock.rs index cc1ee2bd..71bf64ce 100644 --- a/crates/benilla-ui/src/script/tests/model_clock.rs +++ b/crates/benilla-ui/src/script/tests/model_clock.rs @@ -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::("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::("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::("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] diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 530d4a20..ff63a3fb 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -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 = 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();