compat: RegisterUnitEvent, the fixed-strata pair and SetWordWrap

The next three blockers of the `Addon_Ports` pair, each measured with `addon_harness` before
and after rather than picked off a list.

**`RegisterUnitEvent(event, unit1 [, unit2])`** — Cinematic's load blocker, and
`docs/ADDON_COMPAT.md` already had it on the "plausible and untraced" line. The registration
is the stock one: same `event_to_frames` list, same registration order, and
`Model::frame_unit_events` holds the units per frame per event. `tick.rs`'s walk skips a
frame whose row for this event does not name `arg1`; the map is empty on an install with no
such addon and the walk tests `is_empty()` first, so the common path costs one load.

All three unregister paths drop the row, including the one that is easy to miss: a plain
`RegisterEvent` after a unit register **widens** the registration back, as upstream's does —
the frame keeps its place and loses the filter. A row surviving an unregister/re-register
cycle would swallow that event forever, so the test walks the whole cycle.
`Cinematic/Frames.lua:2611` registers `UNIT_AURA` for `"player"` at file scope, so the nil
method stopped the file dead.

**`SetFixedFrameStrata`/`SetFixedFrameLevel`** are no-ops, and that is the honest answer
rather than a stub: 9.0 added them to tell the *frame manager* to stop re-deciding a frame's
strata and level, and this client has no such manager — both are whatever the setters last
wrote and nothing else rewrites them, so the guarantee the caller asks for already holds.
Ace3 calls both unguarded (`AceConfigDialog-3.0.lua:575-576`).

**`SetWordWrap`/`CanWordWrap`** is 3.x's whether-to-wrap switch; 1.12's `SetNonSpaceWrap` is
a different question (where a wrap may fall, not whether one happens). **Stored, not
applied**, on the footing `SetSpacing` and `non_space_wrap` already set in `types.rs`: the
text layout wraps to the declared width unconditionally and has no one-line-and-clip mode for
the flag to drive, so honouring it would mean inventing a layout mode. Kept because the pair
must round-trip and because the missing *setter* is what costs —
`Cinematic/Options.lua:4130` calls it on a label and aborted the options panel there.

All four are registered in `widget_surface.rs`'s `ALLOWED_BEYOND` with their call sites. The
first `RegisterUnitEvent` row said "Frame" and the wall rejected it: event registration lives
on the one shared frame method table, which is where 1.12 keeps `RegisterEvent` too, so the
two are registered alike on all twenty frame kinds or neither is.

Movement: Cinematic's load `Frames.lua:2611` → `CameraWheel.lua:243`, session
`Options.lua:4130` → clear; openmap `AceConfigDialog:573` → `:575`.

benilla-ui 2077 passed, 0 failed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-01 14:45:36 -07:00
co-authored by Claude Opus 5
parent 79e2494fc6
commit e731a77a42
6 changed files with 223 additions and 1 deletions
@@ -198,6 +198,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.unwrap_or(0.0))
})?,
)?;
// `SetWordWrap(on)` / `CanWordWrap()`: 3.x's switch for whether a FontString wraps at all.
// 1.12's own `SetNonSpaceWrap` is a different question (where a wrap may fall, not whether
// one happens), so there is nothing to spell this as.
//
// **Stored, not applied**, on the same footing as `SetSpacing` and for the same stated
// reason: this client's text layout wraps to the declared width unconditionally and has no
// one-line-and-clip mode for the flag to drive, so honouring it would be inventing a
// layout mode, while answering nothing costs an addon its whole build —
// `Cinematic/Options.lua:4130` calls it on a label and aborted the options panel there.
// The pair round-trips, so a caller that reads back what it wrote gets its own answer.
fs.set(
"SetWordWrap",
lua.create_function(|lua, (this, on): (Table, Option<Value>)| {
let rh = crate::script::region::region_handle_of(lua, &this)?;
let on = !matches!(on, None | Some(Value::Nil) | Some(Value::Boolean(false)));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_data.entry(rh).or_default().word_wrap = Some(on);
Ok(())
})?,
)?;
fs.set(
"CanWordWrap",
lua.create_function(|lua, this: Table| {
let rh = crate::script::region::region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let on = model
.region_data
.get(&rh)
.and_then(|d| d.word_wrap)
.unwrap_or(true);
Ok(crate::script::binding_abi::flag(on))
})?,
)?;
}
let m: Table = lua.named_registry_value(REG_FRAME_METHODS)?;
@@ -225,6 +259,62 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `RegisterUnitEvent(event, unit1 [, unit2])`: the stock registration, narrowed to the units
// whose fire the frame wants. Everything about the dispatch stays the reference's — the frame
// goes into the same `event_to_frames` list in the same registration order — and the filter is
// read in `tick.rs`'s walk, which skips a frame whose row for this event does not name `arg1`.
//
// Upstream added it so a unit frame could stop waking for 39 other raid members' `UNIT_AURA`;
// here the point is that addons written for it call it **unguarded**. `Cinematic/Frames.lua`
// registers `UNIT_AURA` for `"player"` this way at file scope (`:2611`), so the nil method
// stopped the file dead and took the rest of the addon's frame work with it.
//
// With no unit named at all this is a plain `RegisterEvent`: upstream's C function raises on a
// missing `unit1`, and widening is the one outcome that cannot silently lose an event.
m.set(
"RegisterUnitEvent",
lua.create_function(
|lua, (this, event, u1, u2): (Table, String, Option<String>, Option<String>)| {
let h = frame_handle_of(lua, &this)?;
let units: Vec<String> = [u1, u2].into_iter().flatten().collect();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let list = model.event_to_frames.entry(event.clone()).or_default();
if !list.contains(&h) {
list.push(h);
}
model
.frame_events
.entry(h)
.or_default()
.insert(event.clone());
let rows = model.frame_unit_events.entry(h).or_default();
if units.is_empty() {
rows.remove(&event);
} else {
// A re-register replaces the unit list rather than adding to it, as upstream's
// does: the call states the whole filter.
rows.insert(event, units);
}
Ok(())
},
)?,
)?;
// `SetFixedFrameStrata(on)` / `SetFixedFrameLevel(on)`: 9.0 added these so a frame could tell
// the *frame manager* to stop re-deciding its strata and level when a parent moved. This
// client has no such manager — strata and level are whatever `SetFrameStrata`/`SetFrameLevel`
// last wrote, and nothing else ever rewrites them — so the guarantee the caller is asking for
// already holds, and a no-op is the honest answer rather than a stub standing in for absent
// behaviour. Ace3 calls both unguarded right after building its dialog
// (`AceConfigDialog-3.0.lua:575-576`), which is where OpenMapClassic stopped once the border
// template existed.
for name in ["SetFixedFrameStrata", "SetFixedFrameLevel"] {
m.set(
name,
lua.create_function(|_, (_this, _on): (Table, Option<Value>)| Ok(()))?,
)?;
}
// `GetEffectiveAlpha()`: the propagated alpha the arena already resolves, where the stock
// `GetAlpha` answers only this frame's own factor.
m.set(
@@ -420,7 +510,7 @@ pub(in crate::script) fn dispatch_attribute_click(
#[cfg(test)]
mod tests {
use crate::script::UiScript;
use crate::script::{ScriptValue, UiScript};
fn frame(s: &UiScript) {
s.run(r#"f = CreateFrame("Frame", "CAFrame")"#).unwrap();
@@ -778,6 +868,56 @@ mod tests {
}
}
/// `RegisterUnitEvent` narrows the fire to the units it names and changes nothing else: the
/// frame sits in the same list in registration order, a fire for another unit is simply not
/// delivered, and a plain `RegisterEvent` afterwards widens it back — the shape upstream has,
/// and the one `Cinematic/Frames.lua:2611` registers `UNIT_AURA` for `"player"` with.
#[test]
fn register_unit_event_hears_only_its_units_until_a_plain_register_widens_it() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
seen = ""
local f = CreateFrame("Frame", "UnitFiltered")
f:RegisterUnitEvent("UNIT_AURA", "player", "pet")
f:SetScript("OnEvent", function() seen = seen .. arg1 .. "," end)
"#,
)
.unwrap();
assert_eq!(
s.eval::<u32>(r#"return UnitFiltered:IsEventRegistered("UNIT_AURA")"#)
.unwrap(),
1,
"the registration itself is the stock one"
);
for unit in ["target", "player", "party1", "pet"] {
s.fire_event("UNIT_AURA", vec![ScriptValue::Str(unit.into())]);
}
assert_eq!(
s.eval::<String>("return seen").unwrap(),
"player,pet,",
"only the two named units were delivered"
);
// A plain register widens it, and the frame keeps its place rather than re-queueing.
s.run(r#"UnitFiltered:RegisterEvent("UNIT_AURA") seen = """#)
.unwrap();
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
assert_eq!(s.eval::<String>("return seen").unwrap(), "target,");
// And unregistering drops the filter with the registration, so a later plain register is
// not still narrowed by a stale row.
s.run(
r#"UnitFiltered:RegisterUnitEvent("UNIT_AURA", "player")
UnitFiltered:UnregisterEvent("UNIT_AURA")
UnitFiltered:RegisterEvent("UNIT_AURA")
seen = """#,
)
.unwrap();
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
assert_eq!(s.eval::<String>("return seen").unwrap(), "target,");
}
/// `SetShown` on a region is a composition, so it is only correct while the pair it composes
/// still reaches the leaf tables. `REGION_LEAF_SHARED` registers them per-leaf rather than on
/// the Region map, which is the kind of arrangement a tidying pass moves — pinned here so that
+9
View File
@@ -712,6 +712,14 @@ pub(crate) struct Model {
/// dispatches in that order across frames. Re-registering keeps a frame's position.
pub(crate) event_to_frames: HashMap<String, Vec<FrameHandle>>,
pub(crate) frame_events: HashMap<FrameHandle, HashSet<String>>,
/// ClassicAPI's `RegisterUnitEvent` (`compat/classic_api/frame.rs`): per frame, per event, the
/// unit tokens whose fire this frame wants. A frame with a row for an event is dispatched only
/// when `arg1` is one of them, which is the whole of what the modern verb does — the
/// registration itself is the stock one, in the stock list, in registration order.
///
/// Empty for every frame that used plain `RegisterEvent`, and `fire_event_into` checks the map
/// is non-empty before looking anything up, so an install with no such addon pays one load.
pub(crate) frame_unit_events: HashMap<FrameHandle, HashMap<String, Vec<String>>>,
/// ClassicAPI's `SetAttribute`/`GetAttribute` (`compat/classic_api/frame.rs`): a plain
/// per-frame key/value store, which is all those two verbs fundamentally are. The *secure*
/// half of the modern secure-template system (restricted execution, the state and attribute
@@ -1986,6 +1994,7 @@ impl Model {
model_facts_wanted: Vec::new(),
dirty_editboxes: Vec::new(),
event_to_frames: HashMap::new(),
frame_unit_events: HashMap::new(),
frame_events: HashMap::new(),
frame_attributes: HashMap::new(),
all_event_frames: Vec::new(),
@@ -25,6 +25,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if !list.contains(&h) {
list.push(h);
}
// A plain register after `RegisterUnitEvent` widens the registration back to every
// unit, as it does upstream: the frame keeps its place in the list and loses the
// filter.
if let Some(filters) = model.frame_unit_events.get_mut(&h) {
filters.remove(&event);
}
model.frame_events.entry(h).or_default().insert(event);
Ok(())
})?,
@@ -40,6 +46,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if let Some(set) = model.frame_events.get_mut(&h) {
set.remove(&event);
}
if let Some(filters) = model.frame_unit_events.get_mut(&h) {
filters.remove(&event);
}
Ok(())
})?,
)?;
@@ -63,6 +72,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let events = model.frame_events.remove(&h).unwrap_or_default();
model.frame_unit_events.remove(&h);
for event in events {
if let Some(list) = model.event_to_frames.get_mut(&event) {
list.retain(|x| x != &h);
@@ -290,6 +290,44 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[
"ClassicAPI: the laid-out wrapped height, the other half of GetStringWidth's measure — \
AceGUI-3.0's Label and CheckBox call it unguarded",
),
(
"CanWordWrap",
"FontString",
"ClassicAPI: the read half of SetWordWrap, so the pair round-trips. True by default, \
which is what this client's layout actually does",
),
(
"SetFixedFrameLevel",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI: 9.0 told the frame manager to stop re-deciding a frame's level. There is no \
such manager here — level is whatever SetFrameLevel last wrote and nothing rewrites it — \
so the guarantee already holds and the no-op is the honest answer. Ace3 calls it \
unguarded at AceConfigDialog-3.0.lua:576",
),
(
"SetFixedFrameStrata",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI: as SetFixedFrameLevel, for strata. Ace3, AceConfigDialog-3.0.lua:575",
),
(
"SetWordWrap",
"FontString",
"ClassicAPI: 3.x's whether-to-wrap switch, which 1.12 has no spelling for (its \
SetNonSpaceWrap decides WHERE a wrap may fall, not whether one happens). Stored, not \
applied, like SetSpacing and for the same reason — the layout wraps to the declared \
width unconditionally — and kept because the missing setter cost Cinematic its whole \
options panel at Options.lua:4130",
),
(
"RegisterUnitEvent",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI: the stock registration narrowed to named units — same list, same order, and \
tick.rs's walk skips a frame whose row for the event does not name arg1. Addons written \
for it do not guard it: Cinematic/Frames.lua:2611 registers UNIT_AURA for \"player\" at \
file scope, so the nil method stopped the file dead. On every frame kind rather than \
Frame alone because event registration lives on the one shared frame method table, which \
is where 1.12 keeps RegisterEvent too — the two are registered alike or neither is",
),
(
"SetColorTexture",
"Texture",
+16
View File
@@ -121,7 +121,23 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) ->
.get(event)
.and_then(|l| l.get(pos + 1).copied());
let id = model.frame_id(h);
// ClassicAPI's `RegisterUnitEvent`: a frame holding a unit filter for this event hears
// it only for those units (`arg1`). The map is empty on an install with no such addon,
// and a frame that registered plainly has no row, so the common path is one
// `is_empty` and nothing else.
let filtered_out = !model.frame_unit_events.is_empty()
&& model
.frame_unit_events
.get(&h)
.and_then(|m| m.get(event))
.is_some_and(|units| {
!matches!(args.first(), Some(ScriptValue::Str(u)) if units.contains(u))
});
drop(model);
if filtered_out {
at = next;
continue;
}
reach.reached += 1;
if let Err(e) = event::fire_event_handler(lua, id, event, &args) {
model_mut().record_script_error(e.to_string());
+9
View File
@@ -441,6 +441,15 @@ pub(crate) struct RegionData {
/// `SetNonSpaceWrap`/`CanNonSpaceWrap` (`0x79e9f0`/`0x79ead0`), `None` on. Stored, not applied:
/// the reference's flag `0x40` drives a mid-word wrap and the ellipsis fit count (`0x771ec0`).
pub(crate) non_space_wrap: Option<bool>,
/// `SetWordWrap`/`CanWordWrap` (`compat/classic_api/frame.rs`), `None` on — 3.x's switch for
/// whether a FontString wraps at all, which 1.12 has no counterpart to (its own
/// `SetNonSpaceWrap` above decides *where* a wrap may fall, not whether one happens).
/// **Stored, not applied**, the same footing as [`Self::spacing`]: this client's text layout
/// wraps to the declared width unconditionally and has no "one line, clipped" mode for the
/// number to drive. Kept because the pair must round-trip and because the absence of the
/// *setter* is what costs — `Cinematic/Options.lua:4130` calls it on a label and aborted the
/// whole options build there.
pub(crate) word_wrap: Option<bool>,
/// `SetSpacing`/`GetSpacing`: extra pixels between the lines of a wrapped FontString.
/// **Stored, not applied** — this client's text layout models no line gap at all, so there is
/// nothing for the number to drive yet. Kept rather than discarded because the pair must