diff --git a/crates/benilla-app/src/capture/probe_charter.rs b/crates/benilla-app/src/capture/probe_charter.rs index 1c38878e..73c5520b 100644 --- a/crates/benilla-app/src/capture/probe_charter.rs +++ b/crates/benilla-app/src/capture/probe_charter.rs @@ -351,7 +351,7 @@ fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec field 188 + 529 = PLAYER_NEXT_LEVEL_XP + // 5df81e fild [ebp+8] + // 5df821 fmul ds:0x80ae90 ; 0.05f + // 5df827 call 0x40a2b0 ; _ftol — TRUNCATES, not rounds + // ``` + // + // and `0x5df806`'s arm pushes **0** when the object is not the local player, which is why + // an absent field answers 0 here rather than suppressing the event. + // + // Nothing in 1.12 FrameXML reads it — `UIParent.lua:399` asks `GetResSicknessDuration()` + // instead — so this is fidelity for the callers that are not ours, exactly 1776's reason + // for settling `PLAYERBANKSLOTS_CHANGED`'s shape when nothing read that either. + let xp_cost = store + .0 + .player_next_level_xp() + .map_or(0, |next| (f64::from(next) * 0.05) as i64); + script.fire_event("CONFIRM_XP_LOSS", vec![ScriptValue::Int(xp_cost)]); } // ── The corpse-run range gate (0308 §5): fires the reference's range events on the edges. ── diff --git a/crates/benilla-app/src/hover_log.rs b/crates/benilla-app/src/hover_log.rs index 16d1eafc..cc39c7bd 100644 --- a/crates/benilla-app/src/hover_log.rs +++ b/crates/benilla-app/src/hover_log.rs @@ -111,7 +111,7 @@ impl Plugin for HoverLogPlugin { fn tooltip_context(script: &UiScript) -> Option<(String, i64, String)> { let chunk = r#" if not GameTooltip or not GameTooltip:IsShown() then return "" end - local owner = GameTooltip:GetOwner() + local owner = GameTooltip:BenillaGetTooltipOwner() local name = "(no owner)" if owner and owner.GetName and owner:GetName() then name = owner:GetName() end local first = "" diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index fe7871a5..3f5d2a0a 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -91,6 +91,13 @@ struct CharFeedMemo { last_stats: Option, last_inv: Option, last_bank_bags: Option, + /// The item guid **of the bag itself** in each of the six bank bag slots (absolute player + /// slots 63..68). `PLAYERBANKSLOTS_CHANGED`'s producer discriminator for this band — the same + /// question `ui_items::feed`'s `SlotGuids::vault` answers for the vault, and for the same + /// reason: the pushed view cannot tell two instances of one bag model apart, so a swap of two + /// identical bags between two slots reads as no change and the reference fires twice + /// (decision 2140). + last_bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT], /// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed` /// for this feed (the equipment templates' ask-once, the enchant-name creator lookups). items_objects: gate::Watch, @@ -1186,15 +1193,26 @@ pub(crate) fn feed_char( // One transition for both bands: they are the same descriptor read at two offsets, and // `UNIT_INVENTORY_CHANGED` is the one repaint signal either has. Pushing them separately // would fire it twice for a single wire update. + // `PLAYERBANKSLOTS_CHANGED`'s two producers, planned off the OLD memo before the push + // replaces it. `false` = P1, the player-descriptor path (`0x5ddd6e`, no arguments — a bag + // arriving, leaving or exchanged); `true` = P2, the item-object path (`0x4c728d`, + // `arg1 = "player"` — the same bag, its own fields changed). The full carve and why the guid + // is the only sound discriminator are at `ui_items::feed`'s vault twin; decision 2140. + let bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT] = + std::array::from_fn(|i| store.0.player_bank_bag_slot(i as u8).unwrap_or(0)); + let repainted: Vec = (0..BANK_BAG_SLOT_COUNT) + .filter_map(|i| { + if bank_bag_guids[i] != memo.last_bank_bag_guids[i] { + Some(false) + } else { + let was = memo.last_bank_bags.as_ref().and_then(|b| b[i].as_ref()); + (!same_item(was, bank_bags[i].as_ref())).then_some(true) + } + }) + .collect(); + memo.last_bank_bag_guids = bank_bag_guids; if memo.last_inv.as_ref() != Some(&inv) || memo.last_bank_bags.as_ref() != Some(&bank_bags) { gate.audit("feed_char", "the inventory snapshot"); - // Read off the OLD memo, before the push replaces it. - let repainted: Vec = (0..BANK_BAG_SLOT_COUNT) - .filter(|&i| { - let was = memo.last_bank_bags.as_ref().and_then(|b| b[i].as_ref()); - !same_item(was, bank_bags[i].as_ref()) - }) - .collect(); script.set_inventory_slots(inv.clone()); script.set_bank_bag_slots(bank_bags.clone()); script.fire_event( @@ -1213,20 +1231,29 @@ pub(crate) fn feed_char( // announces the band whose data it owns, and `feed_char` is ordered first, so both fire // after their own push. // - // **It carries NO arguments** (CARVED — wow-re `system/object-layer/scratch/ - // bank-slot-event-law.md`). The descriptor watcher's fire site `0x5ddd6e` calls - // `FrameScript_SignalEvent 0x703e50`, which is `__fastcall(ecx = id)` with a plain `ret` - // and no vararg push at all — zero Lua values. (The image's only other fire site, - // `0x4c728d` in the item-GUID→slot notifier, pushes the literal string `"player"`, not a - // slot; nothing in FrameXML reads either, both consumers branching on `event` alone.) An - // event is fired per changed slot, as the watcher does — the slot travels in *how many* - // times it fires, never in an argument. - for _ in repainted { - script.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]); - } + // **Which arguments it carries depends on WHICH producer fired** (CARVED — wow-re + // `system/object-layer/scratch/bank-slot-event-law.md` §3/§4, folded back in 2140). The + // descriptor watcher `0x5ddd6e` calls `FrameScript_SignalEvent 0x703e50`, an + // `__fastcall(ecx = id)` with a plain `ret` and no vararg push — zero Lua values; the + // item-object notifier `0x4c728d` goes through `SignalEvent2` and pushes the literal + // string `"player"`. benilla fired the argless one for both until 2140. An event is fired + // per changed slot, as the watcher does — the slot travels in *how many* times it fires, + // never in an argument (1776). memo.last_inv = Some(inv); memo.last_bank_bags = Some(bank_bags); } + // Outside the push block, because the identical-bag swap it exists for moves no view at all. + if !repainted.is_empty() { + gate.audit("feed_char", "a bank bag slot transition"); + } + for same_bag in repainted { + let args = if same_bag { + vec![ScriptValue::Str("player".to_string())] + } else { + Vec::new() + }; + script.fire_event("PLAYERBANKSLOTS_CHANGED", args); + } } /// Whether two bank-bag views hold the same ITEM — everything but `locked`, which is @@ -1342,6 +1369,8 @@ mod tests { const F_BANK_BAG_1: u16 = 612; /// `OBJECT_FIELD_ENTRY` on the item object. const F_OBJECT_ENTRY: u16 = 3; + /// `ITEM_FIELD_STACK_COUNT` — one of the six item fields the reference's own watcher covers. + const F_ITEM_STACK_COUNT: u16 = 14; /// "Traveler's Backpack" — any container entry; the feed only needs it to resolve. const BAG_ENTRY: u32 = 4500; const BAG: u64 = 0x4000_0000_0000_0abc; @@ -1420,6 +1449,74 @@ mod tests { ); } + /// **The second producer** (decision 2140). The same bag, its own `ITEM_FIELD_STACK_COUNT` + /// moving, is not the descriptor path — it is `0x4c7180`'s item-object path, which goes + /// through `SignalEvent2` and pushes the unit token `"player"`. benilla fired the argless + /// descriptor shape for both. + #[test] + fn a_bank_bags_own_field_moving_fires_the_item_object_producer() { + let mut app = world_with_bank_bag(Some(BAG)); + app.world_mut().run_system_once(feed_char).unwrap(); + assert!(seen(&mut app).contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string())); + + // The SAME bag guid, restacked. Only the item object moved. + app.world_mut().resource_mut::().insert_object( + BAG, + ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY), (F_ITEM_STACK_COUNT, 3)]), + ); + app.world_mut().run_system_once(feed_char).unwrap(); + let events = seen(&mut app); + assert!( + events.contains(&"PLAYERBANKSLOTS_CHANGED player".to_string()), + "the item path pushes the token, got {events:?}" + ); + } + + /// Two IDENTICAL bags exchanged between two bank bag slots — the case the pushed view cannot + /// see, because two instances of one bag model push the same `InvSlotView`. The reference + /// watches the descriptor GUIDs, so it fires the argless event twice; benilla, diffing the + /// view, fired nothing at all. Same shape as 1777's `BAG_CLOSED`, one band over. + #[test] + fn two_identical_bags_swapped_between_bank_bag_slots_still_announce() { + let mut app = world_with_bank_bag(Some(BAG)); + // A second, byte-identical bag in bank bag slot 2. + const BAG2: u64 = 0x4000_0000_0000_0abd; + // The two bank bag slots' guids, rewritten wholesale — `ObjectFields` has no setter, and + // a fresh store is what a descriptor update produces anyway. + let set_slots = |app: &mut App, a: u64, b: u64| { + let mut q = app + .world_mut() + .query_filtered::<&mut ObjectStore, With>(); + let mut store = q.single_mut(app.world_mut()).unwrap(); + *store = ObjectStore(ObjectFields::from_pairs(&[ + (F_BANK_BAG_1, a as u32), + (F_BANK_BAG_1 + 1, (a >> 32) as u32), + (F_BANK_BAG_1 + 2, b as u32), + (F_BANK_BAG_1 + 3, (b >> 32) as u32), + ])); + }; + set_slots(&mut app, BAG, BAG2); + app.world_mut().resource_mut::().insert_object( + BAG2, + ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]), + ); + app.world_mut().run_system_once(feed_char).unwrap(); + let _ = seen(&mut app); + + // Exchange them. Every pushed view is equal before and after. + set_slots(&mut app, BAG2, BAG); + app.world_mut().run_system_once(feed_char).unwrap(); + let events = seen(&mut app); + assert_eq!( + events + .iter() + .filter(|e| *e == "PLAYERBANKSLOTS_CHANGED nil") + .count(), + 2, + "one argless event per slot whose guid moved, got {events:?}" + ); + } + /// The other half of the same rule: a LOCK flip is not a slot change. The reference has a /// separate event for it (`ITEM_LOCK_CHANGED`, fired by `ui_items::feed_containers` off /// `LockTransitions`), and firing the repaint event for it too would announce a change to the diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 56da9012..644a0c38 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -6,7 +6,7 @@ use bevy::prelude::*; use benilla_protocol::messages::BAG_PLAYER_INVENTORY; -use benilla_ui::script::{ScriptValue, UiScript, EQUIPMENT_BAG}; +use benilla_ui::script::{UiScript, EQUIPMENT_BAG}; use crate::items::Items; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; @@ -574,10 +574,7 @@ pub(super) fn drain_container_uses( .map(|store| slot_guid_count(Some(store), bag, slot, &ladder.items)) .unwrap_or((0, 0)); pending_items.add([(bag, slot, guid, count)]); - script.fire_event( - "ITEM_LOCK_CHANGED", - vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))], - ); + script.fire_event("ITEM_LOCK_CHANGED", Vec::new()); let _ = ladder.commands.0.send(ClientCommand::OpenItem { bag_index, slot: wire_slot, @@ -831,11 +828,10 @@ pub(crate) fn send_container_move( (mv.src_bag, mv.src_slot, src_guid, src_count), (mv.dst_bag, mv.dst_slot, dst_guid, dst_count), ]); - for (bag, slot) in [(mv.src_bag, mv.src_slot), (mv.dst_bag, mv.dst_slot)] { - script.fire_event( - "ITEM_LOCK_CHANGED", - vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))], - ); + // One per locked end, as the reference's own per-slot unlock does — the slot travels in + // how many times it fires, never in an argument (see `feed`'s note). + for _ in 0..2 { + script.fire_event("ITEM_LOCK_CHANGED", Vec::new()); } } true @@ -873,10 +869,7 @@ pub(super) fn drain_container_destroys( }); let (guid, stack) = slot_guid_count(store, bag, slot, &items); pending.add([(bag, slot, guid, stack)]); - script.fire_event( - "ITEM_LOCK_CHANGED", - vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))], - ); + script.fire_event("ITEM_LOCK_CHANGED", Vec::new()); } } diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 8766f53f..1d02ee7c 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -50,6 +50,29 @@ pub(crate) struct FeedMemory { /// [`ContainerState`] therefore cannot answer: two different empty 16-slot bags swapped /// between two slots produce identical container states and must still close both windows. bag_guids: [u64; 10], + /// The item guid in each of the **24 vault slots** (absolute player slots 39..62), indexed + /// `slot − 1`. Diffed frame to frame to tell `PLAYERBANKSLOTS_CHANGED`'s two producers apart — + /// see [`SlotGuids`] and the fire site below. Same reason [`Self::bag_guids`] exists: the + /// pushed [`ContainerState`] cannot answer a question about item IDENTITY, because two + /// different instances of one template push the same slot view. + vault_guids: [u64; BANK_SLOTS as usize], +} + +/// The player descriptor's own item-slot guids, as the reference's watchers read them — the ten +/// bag slots and the 24 vault slots, in one value because they are one registration family +/// (`0x5dd8a0` installs `0x5ddcf0` over four bands of `PLAYER_FIELD_*_SLOT_*` with `len = 8`, the +/// guid's width). +/// +/// These ride beside the pushed containers rather than inside them because they are read off the +/// player descriptor, not built from the containers — and because they answer what a derived view +/// cannot: *which item*, not *what it looks like*. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub(crate) struct SlotGuids { + /// Container ids 1..=10 — the four equipped bag slots, then the six bank bag slots — indexed + /// `id − 1`. `BAG_CLOSED`'s diff (decision 1777). + pub bags: [u64; 10], + /// The 24 vault slots, indexed `slot − 1`. `PLAYERBANKSLOTS_CHANGED`'s producer discriminator. + pub vault: [u64; BANK_SLOTS as usize], } /// A spell's tooltip text: the $-substituted description (the real "Use: Restores 392 to 653 @@ -1276,7 +1299,10 @@ pub(crate) fn feed_containers( &mut script, memory, player.is_some().then_some(fresh), - std::array::from_fn(|i| player.map_or(0, |s| bag_slot_guid(s, i as i64 + 1))), + SlotGuids { + bags: std::array::from_fn(|i| player.map_or(0, |s| bag_slot_guid(s, i as i64 + 1))), + vault: std::array::from_fn(|i| player.map_or(0, |s| vault_slot_guid(s, i as u8))), + }, transitioned, ) { gate.audit("feed_containers", "a bag diff or a lock event"); @@ -1284,8 +1310,8 @@ pub(crate) fn feed_containers( } /// The feed's outward half: diff `source` against what was last pushed, push each changed bag into -/// the VM and fire the reference's events — `BAG_UPDATE(bagID)` (`PLAYERBANKSLOTS_CHANGED(slot)` -/// for the vault), one `BAG_UPDATE_DELAYED` per batch, then the lock transitions. +/// the VM and fire the reference's events — `BAG_UPDATE(bagID)` (`PLAYERBANKSLOTS_CHANGED` for +/// the vault), one `BAG_UPDATE_DELAYED` per batch, then the lock transitions. /// /// **`source: None` means the self player STORE is absent this frame — "no data source", never /// "the player has no items."** The two absent windows are pre-arrival at login (the fresh VM's @@ -1305,11 +1331,12 @@ pub(crate) fn apply_container_source( script: &mut UiScript, memory: &mut FeedMemory, source: Option>, - // The ten bag slots' own guids ([`bag_slot_guid`]) — `BAG_CLOSED`'s diff. Rides beside the - // source rather than inside it because it is read off the player descriptor, not built from - // the containers, and the absent-source path must leave the memory alone rather than diff - // against zeros (that path is the logout despawn window — see this function's own note). - bag_guids: [u64; 10], + // The player's item-slot guids ([`bag_slot_guid`], [`vault_slot_guid`]) — `BAG_CLOSED`'s diff + // and `PLAYERBANKSLOTS_CHANGED`'s producer discriminator. Rides beside the source rather than + // inside it because it is read off the player descriptor, not built from the containers, and + // the absent-source path must leave the memory alone rather than diff against zeros (that path + // is the logout despawn window — see this function's own note). + guids: SlotGuids, transitioned: Vec<(i64, u32)>, ) -> bool { // Diff whole bags; push + fire BAG_UPDATE per transition, one BAG_UPDATE_DELAYED per batch. A @@ -1318,16 +1345,20 @@ pub(crate) fn apply_container_source( // below rather than leaning on that invariant. let mut pushed = false; if let Some(fresh) = source { - pushed |= diff_and_push(script, memory, fresh, bag_guids); + pushed |= diff_and_push(script, memory, fresh, guids); } // The lock-transition event (decision 0218: the bag windows' own repaint trigger) — after the // container push above, so a listener's repaint reads the corrected `.locked` state. pushed |= !transitioned.is_empty(); - for (bag, slot) in transitioned { - script.fire_event( - "ITEM_LOCK_CHANGED", - vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))], - ); + // **`ITEM_LOCK_CHANGED` carries NO arguments** (decision 2140, found by the argument gate). + // All five fire sites in the image — `0x495415`, `0x495455`, `0x49557d` (the local lock/unlock + // paths), `0x5d859a` and `0x5d94b9` (the item field watchers) — are + // `FrameScript_SignalEvent 0x703e50`, an `__fastcall(ecx = id)` with a plain `ret` and no vararg + // mechanism at all, so the event cannot carry one. Every stock consumer repaints from `this` + // (`ContainerFrame.lua:39`, `PaperDollFrame.lua:601`, `BankFrame.lua:209`). The `(bag, slot)` + // pair benilla pushed is the LATER clients' shape, invented here. + for _ in transitioned { + script.fire_event("ITEM_LOCK_CHANGED", Vec::new()); } // Whether anything went into the VM — the caller's gate audit reads it (1439). pushed @@ -1341,6 +1372,12 @@ pub(crate) fn apply_container_source( /// the descriptor bytes `0x540`–`0x55f` (equipped bags 19–22) and `0x6a0`–`0x6cf` (bank bags /// 63–68) with callback `0x4f8ec0`, and the backpack and keyring loops with `0x4f8db0`, which /// never reaches the event at all. So exactly these ten slots close a window. +/// The guid of the item in vault slot `i` (0-based, 24 of them — absolute player slots 39..62). +/// `0` = empty. The band `0x5dd8a0`'s L2 loop registers `0x5ddcf0` over (`edi=0x5e0`..`0x698`). +fn vault_slot_guid(store: &ObjectStore, i: u8) -> u64 { + store.0.player_bank_slot(i).unwrap_or(0) +} + fn bag_slot_guid(store: &ObjectStore, id: i64) -> u64 { match id { 1..=4 => store.0.player_inv_slot(BAG_SLOT_FIRST + (id as u8 - 1)), @@ -1355,7 +1392,7 @@ fn diff_and_push( script: &mut UiScript, memory: &mut FeedMemory, fresh: HashMap, - bag_guids: [u64; 10], + guids: SlotGuids, ) -> bool { // **`BAG_CLOSED` — the only thing in the image that hides an open bag window** (CARVED, wow-re // `system/ui/scratch/equipped-bag-slot-events.md`: one fire site, `0x4f92b5`, and @@ -1371,7 +1408,7 @@ fn diff_and_push( .filter(|&id| { let (was, now) = ( memory.bag_guids[id as usize - 1], - bag_guids[id as usize - 1], + guids.bags[id as usize - 1], ); was != 0 && now != was }) @@ -1381,12 +1418,55 @@ fn diff_and_push( let emptied: Vec = closed .iter() .copied() - .filter(|&id| bag_guids[id as usize - 1] == 0) + .filter(|&id| guids.bags[id as usize - 1] == 0) .collect(); - memory.bag_guids = bag_guids; + memory.bag_guids = guids.bags; for id in &closed { script.fire_event("BAG_CLOSED", vec![ScriptValue::Int(*id)]); } + // **`PLAYERBANKSLOTS_CHANGED` has TWO producers in the reference, and they carry different + // arguments** (CARVED — wow-re `system/object-layer/scratch/bank-slot-event-law.md` §3/§4; + // decision 2140). benilla had one fire for both, so half of them were argless where the + // reference pushes a string: + // + // * **P1, the player-descriptor path** — `0x5ddcf0`'s watcher sees the slot's own GUID field + // change and fires `0x5ddd6e`, `FrameScript_SignalEvent 0x703e50`, `__fastcall(ecx = id)` + // with a plain `ret`: **zero Lua values**. This is an item arriving, leaving, or being + // exchanged for a different one. + // * **P2, the item-object path** — the changed ITEM's own watched fields move (stack count, + // spell charges, enchantment, flags, durability, entry) or its `CGItem` enters the world; + // `0x4c7180` resolves it to a flat slot and fires `0x4c728d`, + // `SignalEvent2(347, "%s", "player")`: **`arg1` is the unit token `"player"`**. This is the + // same item, changed. + // + // So the discriminator is the slot's item GUID, not its pushed view — and that is not a + // refinement, it is the only thing that answers: two different instances of one template push + // an identical `ContainerSlot`, so a view diff reads a swap of two identical stacks as *no + // change at all* and fires nothing where the reference fires P1 twice. Exactly 1777's reason + // for diffing `BAG_CLOSED` on the bag's own guid, one band over. + // + // Planned here, before `memory` is rewritten, and fired below after the push — a listener must + // repaint off the corrected state. It is deliberately OUTSIDE the `changed` gate for the same + // identical-swap reason: that transition moves no container state. + let vault: Vec = { + let empty = HashMap::new(); + let now = fresh.get(&BANK_CONTAINER).map_or(&empty, |c| &c.slots); + let was = memory + .pushed + .get(&BANK_CONTAINER) + .map_or(&empty, |c| &c.slots); + (1..=u32::from(BANK_SLOTS)) + .filter_map(|slot| { + let i = slot as usize - 1; + if guids.vault[i] != memory.vault_guids[i] { + Some(false) + } else { + (now.get(&slot) != was.get(&slot)).then_some(true) + } + }) + .collect() + }; + memory.vault_guids = guids.vault; let changed: Vec = fresh .keys() .chain(memory.pushed.keys()) @@ -1428,12 +1508,12 @@ fn diff_and_push( for &bag in &changed { script.set_container(bag, fresh.get(&bag).cloned()); } - // Ahead of BAG_UPDATE below, which is the reference handler's own order. - for (bag, slot) in restacked { - script.fire_event( - "ITEM_LOCK_CHANGED", - vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))], - ); + // Ahead of BAG_UPDATE below, which is the reference handler's own order. One event per + // restacked slot and no arguments (see the note above) — `restacked` keeps the `(bag, + // slot)` pairs rather than a count because they are what identifies the slot for anyone + // reading this at a breakpoint; the event itself has never carried them. + for _ in &restacked { + script.fire_event("ITEM_LOCK_CHANGED", Vec::new()); } // Name the bags, not just the count: "3 changed" can't tell you WHICH container moved, and // the negative ids (−1 bank, −2 keyring) are exactly the ones you go looking for. @@ -1460,34 +1540,149 @@ fn diff_and_push( // NEITHER `BAG_UPDATE` nor `BAG_CLOSED`: `0x4f8cc0` installs its container listener // over the six bank-bag fields (`0x6a0`–`0x6c8`) and over nothing in the 24 vault // fields (`0x5e0`–`0x698`). - if bag == BANK_CONTAINER { - let empty = HashMap::new(); - let now_slots = fresh.get(&bag).map(|c| &c.slots).unwrap_or(&empty); - let was_slots = memory.pushed.get(&bag).map(|c| &c.slots).unwrap_or(&empty); - for slot in 1..=u32::from(BANK_SLOTS) { - if now_slots.get(&slot) != was_slots.get(&slot) { - script.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]); - } - } - } else if !emptied.contains(&bag) { + if bag != BANK_CONTAINER && !emptied.contains(&bag) { script.fire_event("BAG_UPDATE", vec![ScriptValue::Int(bag)]); } } memory.pushed = fresh; - return true; } - // A `BAG_CLOSED` with nothing else to say still went into the VM — two identical bags swapped - // between two slots change no container state at all. - !closed.is_empty() + // The vault band's announce, planned above. One event per changed slot, as the watcher does — + // the slot travels in *how many times it fires*, never in an argument (1776). + for &same_item in &vault { + let args = if same_item { + vec![ScriptValue::Str("player".into())] + } else { + Vec::new() + }; + script.fire_event("PLAYERBANKSLOTS_CHANGED", args); + } + // A `BAG_CLOSED` or a vault event with nothing else to say still went into the VM — two + // identical bags (or two identical items) swapped between two slots change no container state + // at all. + !changed.is_empty() || !closed.is_empty() || !vault.is_empty() } #[cfg(test)] mod tests { - use super::{apply_container_source, charges_count, FeedMemory}; + use super::{ + apply_container_source, charges_count, ContainerSlot, FeedMemory, SlotGuids, + BANK_CONTAINER, BANK_SLOTS, + }; - /// No bag in any of the ten bag slots — every test below drives the backpack (container 0), - /// which has no bag slot behind it and can never raise `BAG_CLOSED`. - const NO_BAGS: [u64; 10] = [0; 10]; + /// No bag in any of the ten bag slots, nothing in the vault — every test below drives the + /// backpack (container 0), which has no bag slot behind it and can never raise `BAG_CLOSED`. + const NO_BAGS: SlotGuids = SlotGuids { + bags: [0; 10], + vault: [0; BANK_SLOTS as usize], + }; + + /// **`PLAYERBANKSLOTS_CHANGED` has two producers and they carry different arguments** + /// (decision 2140; CARVED, wow-re `object-layer/scratch/bank-slot-event-law.md` §3/§4). + /// + /// benilla fired the argless one for every transition, which is right for half of them and + /// wrong for the other half — and the half it got wrong is the one a view diff cannot even + /// see. The three arms below are the three the carve distinguishes: + /// + /// · a slot's item GUID changes (arrive / leave / exchange) → `0x5ddd6e`, **no arguments**; + /// · the same item's own fields change (a restack) → `0x4c728d`, **`arg1 = "player"`**; + /// · two IDENTICAL items exchanged between two slots → the pushed views are equal, so the + /// container diff sees nothing at all, and the reference fires the argless one **twice**. + #[test] + fn playerbankslots_changed_names_its_producer_by_the_slot_guid() { + let mut s = UiScript::new().unwrap(); + s.run( + "SEEN = {} \ + local f = CreateFrame('Frame') \ + f:RegisterEvent('PLAYERBANKSLOTS_CHANGED') \ + f:SetScript('OnEvent', function() \ + table.insert(SEEN, event .. ' ' .. tostring(arg1)) end)", + ) + .unwrap(); + let seen = |s: &mut UiScript| { + let out = s.eval::>("return SEEN").unwrap(); + s.run("SEEN = {}").unwrap(); + out + }; + // The vault, with `slot` holding `count` of item 1234. + let vault = |slot: u32, count: u32| { + HashMap::from([( + BANK_CONTAINER, + ContainerState { + name: None, + num_slots: u32::from(BANK_SLOTS), + slots: HashMap::from([( + slot, + ContainerSlot { + item_id: 1234, + count, + ..Default::default() + }, + )]), + }, + )]) + }; + let mut guids = NO_BAGS; + let mut memory = FeedMemory::default(); + + // An item ARRIVES in vault slot 1 — the descriptor path, no arguments. + guids.vault[0] = 0xF00D; + apply_container_source(&mut s, &mut memory, Some(vault(1, 5)), guids, Vec::new()); + assert_eq!(seen(&mut s), vec!["PLAYERBANKSLOTS_CHANGED nil"]); + + // The SAME item restacks — its own `ITEM_FIELD_STACK_COUNT` moved, which is the item + // watcher's path, and the reference pushes the unit token there. + apply_container_source(&mut s, &mut memory, Some(vault(1, 9)), guids, Vec::new()); + assert_eq!(seen(&mut s), vec!["PLAYERBANKSLOTS_CHANGED player"]); + + // Nothing moved at all. + apply_container_source(&mut s, &mut memory, Some(vault(1, 9)), guids, Vec::new()); + assert!(seen(&mut s).is_empty(), "an unchanged vault says nothing"); + + // Two IDENTICAL stacks exchanged between slots 1 and 2. Both slot views are equal before + // and after, so `fresh == memory.pushed` and the container diff is empty — the guids are + // the only witness, and the reference fires twice. + let two = || { + HashMap::from([( + BANK_CONTAINER, + ContainerState { + name: None, + num_slots: u32::from(BANK_SLOTS), + slots: HashMap::from([ + ( + 1, + ContainerSlot { + item_id: 1234, + count: 9, + ..Default::default() + }, + ), + ( + 2, + ContainerSlot { + item_id: 1234, + count: 9, + ..Default::default() + }, + ), + ]), + }, + )]) + }; + guids.vault[1] = 0xBEEF; + apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new()); + assert_eq!(seen(&mut s), vec!["PLAYERBANKSLOTS_CHANGED nil"]); + guids.vault.swap(0, 1); + let pushed = apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new()); + assert_eq!( + seen(&mut s), + vec![ + "PLAYERBANKSLOTS_CHANGED nil".to_string(), + "PLAYERBANKSLOTS_CHANGED nil".to_string() + ], + "the swap the pushed containers cannot see" + ); + assert!(pushed, "and the feed reports that it spoke to the VM"); + } /// **`BAG_CLOSED`, the only thing that hides an open bag window** — and the three ways to get /// its condition wrong (CARVED, wow-re `system/ui/scratch/equipped-bag-slot-events.md`). @@ -1535,7 +1730,7 @@ mod tests { // Equip a bag into the first equipped bag slot: BAG_UPDATE, and no BAG_CLOSED — the slot // was empty, which is the `0x4f9247 je` arm. - guids[0] = 0xAAAA; + guids.bags[0] = 0xAAAA; apply_container_source(&mut s, &mut memory, Some(pouch(6)), guids, Vec::new()); assert_eq!(seen(&mut s), vec!["BAG_UPDATE 1"]); @@ -1549,12 +1744,12 @@ mod tests { // SWAP for a different bag: closed FIRST, then updated. This is the arm a "the bag went // away" reading gets wrong — the slot is still occupied. - guids[0] = 0xBBBB; + guids.bags[0] = 0xBBBB; apply_container_source(&mut s, &mut memory, Some(pouch(10)), guids, Vec::new()); assert_eq!(seen(&mut s), vec!["BAG_CLOSED 1", "BAG_UPDATE 1"]); // UNEQUIP: closed, and **no** BAG_UPDATE. - guids[0] = 0; + guids.bags[0] = 0; apply_container_source(&mut s, &mut memory, Some(HashMap::new()), guids, Vec::new()); assert_eq!(seen(&mut s), vec!["BAG_CLOSED 1"]); @@ -1583,11 +1778,11 @@ mod tests { ]) }; let mut guids = NO_BAGS; - guids[4] = 0x1111; - guids[5] = 0x2222; + guids.bags[4] = 0x1111; + guids.bags[5] = 0x2222; apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new()); let _ = seen(&mut s); - guids.swap(4, 5); + guids.bags.swap(4, 5); let pushed = apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new()); assert_eq!(seen(&mut s), vec!["BAG_CLOSED 5", "BAG_CLOSED 6"]); assert!( @@ -1662,8 +1857,9 @@ mod tests { } /// **The spent-ammo signal** (decision 1509, B267's second half). A stack ticking down must - /// fire `ITEM_LOCK_CHANGED` for that slot, **before** the `BAG_UPDATE` for its bag — the - /// reference's `ITEM_FIELD_STACK_COUNT` mirror handler's own order. + /// fire `ITEM_LOCK_CHANGED`, **before** the `BAG_UPDATE` for its bag — the reference's + /// `ITEM_FIELD_STACK_COUNT` mirror handler's own order. The event carries no arguments + /// (2140): `0x5d94b9` is a `SignalEvent` site, which has no vararg mechanism at all. /// /// This is not cosmetic ordering. Quiver's auto-shot timer has no other way to learn a shot /// fired: it starts the reload drain from this event, and without it the bar fills once and @@ -1730,8 +1926,11 @@ mod tests { ); assert_eq!( s.eval::("return table.concat(ORDER, ' ')").unwrap(), - "ITEM_LOCK_CHANGED:0,1 BAG_UPDATE:0,nil", - "the shot signal fires for (bag 0, slot 1) and precedes BAG_UPDATE" + "ITEM_LOCK_CHANGED:nil,nil BAG_UPDATE:0,nil", + "the shot signal fires — with NO arguments, as all five of the reference's fire sites \ + do (decision 2140) — and precedes BAG_UPDATE. The slot it names travels in the fact \ + that it fired at all, which is enough: every consumer repaints from its own `this`, \ + and Quiver's shot timer only needs to know that A shot happened" ); // A DIFFERENT item in the same slot: a swap. The count differs too, and it must still diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs index f91f8541..b822c7fb 100644 --- a/crates/benilla-app/src/ui_mail/mod.rs +++ b/crates/benilla-app/src/ui_mail/mod.rs @@ -584,8 +584,11 @@ fn feed_mail( for index in std::mem::take(&mut mail.close_inbox) { script.fire_event("CLOSE_INBOX_ITEM", vec![ScriptValue::Int(i64::from(index))]); } + // `SMSG_SEND_MAIL_RESULT 0x4ad050`, in its own order (wow-re `mail-interaction.md` §4): the + // GlobalString toast, then — on `action 0 / reason 0` only — the compose-tab reset `0x4acdc0(1)` + // with its three events, and **last, on every path, `MAIL_FAILED`** (`0x4ad15f`, unconditional: + // a successful send fires it too, as the generic "the send resolved, unblock the form" signal). for ack in std::mem::take(&mut mail.send_acks) { - script.fire_event("MAIL_FAILED", vec![]); let key = if ack.ok { Some(MAIL_SENT_KEY) } else { @@ -594,9 +597,9 @@ fn feed_mail( let line = key.and_then(|k| crate::ui_action::keyed_line(&script, k)); crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", line); if ack.ok { - script.fire_event("MAIL_SEND_SUCCESS", vec![]); - script.clear_send_mail_item(); + script.reset_compose_tab(); } + script.fire_event("MAIL_FAILED", vec![]); } // The macro subject, resolved before the row walk borrows the name cache again. `None` until @@ -657,12 +660,12 @@ fn feed_mail( // The selection is cleared on open AND close (`0x4ace07`, 1970); the stock tab's reset // on MAIL_SHOW re-selects row 1. script.clear_stationery(); + // The open core's own order (wow-re `mail-interaction.md` §1): store the mailbox, register + // the interaction target, **reset the compose tab** — a fresh window carries no stale + // attachment or money — and only THEN `MAIL_SHOW`. The reset's `MAIL_SEND_SUCCESS` is the + // byte-verified open side-effect, so the page-turn sound on open is faithful, not a bug. + script.reset_compose_tab(); script.fire_event("MAIL_SHOW", vec![]); - // The compose tab resets on open — a fresh window carries no stale attachment/money. - script.clear_send_mail_item(); - // Byte-verified open side-effect (wow-re §5): the client's compose-tab reset fires - // MAIL_SEND_SUCCESS on open too, so the page-turn sound on open is faithful, not a bug. - script.fire_event("MAIL_SEND_SUCCESS", vec![]); } else if closed { script.clear_stationery(); script.fire_event("MAIL_CLOSED", vec![]); diff --git a/crates/benilla-app/src/ui_pet_stats.rs b/crates/benilla-app/src/ui_pet_stats.rs index 56d62128..f378f325 100644 --- a/crates/benilla-app/src/ui_pet_stats.rs +++ b/crates/benilla-app/src/ui_pet_stats.rs @@ -322,11 +322,22 @@ fn feed_pet_stats( // line is `if ( arg1 == this.unit )`, so an argless fire reaches nobody. script.fire_event("UNIT_HAPPINESS", vec![ScriptValue::Str("pet".into())]); } + // Both of these are the SAME bridge as `UNIT_HAPPINESS` above — named unit-descriptor fields + // (141/142 `PET_EXPERIENCE`/`PET_NEXT_LEVEL_EXP`, 149 `TRAINING_POINTS`), whose events the + // reference dispatches through `0x515e50`'s token fan-out, `SignalEvent2(id, "%s", token)`. + // They were argless here for months, two lines under a comment stating the law, and the cost + // was not theoretical: `PetPaperDollFrame_OnEvent` gives `UNIT_PET_EXPERIENCE` a named branch + // but routes `UNIT_PET_TRAINING_POINTS` to its final `elseif ( arg1 == "pet" )` catch-all + // (`PetPaperDollFrame.lua:44`), so an argless fire reached nobody and the pet page's training + // points never repainted off the event at all. if xp_moved { - script.fire_event("UNIT_PET_EXPERIENCE", vec![]); + script.fire_event("UNIT_PET_EXPERIENCE", vec![ScriptValue::Str("pet".into())]); } if training_moved { - script.fire_event("UNIT_PET_TRAINING_POINTS", vec![]); + script.fire_event( + "UNIT_PET_TRAINING_POINTS", + vec![ScriptValue::Str("pet".into())], + ); } } diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index 80635e0d..89615c67 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -1182,7 +1182,7 @@ fn the_split_frame_raises_over_a_same_stratum_window() { local w = CreateFrame("Frame", "FakeBagnon", UIParent) w:SetFrameStrata("HIGH") w:SetPoint("BOTTOMLEFT", 0, 0) - w:SetSize(1024, 768) + w:SetWidth(1024); w:SetHeight(768) local bg = w:CreateTexture(nil, "BACKGROUND") bg:SetTexture("Interface\\FakeBagnonBG") bg:SetAllPoints() diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index 3d477ea0..d87a6238 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -1395,7 +1395,13 @@ fn bagnon_forevers_records_survive_the_logout_boundary() { // The logout boundary: the despawn frame (no self store) and then the shutdown's own events. // The record the write persists must be the bags the player actually had. let mut memory = crate::ui_items::feed::FeedMemory::default(); - crate::ui_items::feed::apply_container_source(&mut s, &mut memory, None, [0; 10], Vec::new()); + crate::ui_items::feed::apply_container_source( + &mut s, + &mut memory, + None, + Default::default(), + Vec::new(), + ); s.fire_event("PLAYER_LEAVING_WORLD", Vec::new()); s.fire_event("PLAYER_LOGOUT", Vec::new()); let (size, item) = s diff --git a/crates/benilla-app/src/ui_script/buff_tests.rs b/crates/benilla-app/src/ui_script/buff_tests.rs index 35f007d6..afe1d717 100644 --- a/crates/benilla-app/src/ui_script/buff_tests.rs +++ b/crates/benilla-app/src/ui_script/buff_tests.rs @@ -1038,7 +1038,7 @@ fn the_duration_line_reads_the_real_global_strings() { s.run( r#" BENILLA_ANCHOR = CreateFrame("Button", "BF9") - BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10) + BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetWidth(10); BENILLA_ANCHOR:SetHeight(10) BENILLA_TIP = CreateFrame("GameTooltip", "TT9") "#, ) diff --git a/crates/benilla-app/src/ui_script/delete_item_tests.rs b/crates/benilla-app/src/ui_script/delete_item_tests.rs index fca90ec3..f1b15482 100644 --- a/crates/benilla-app/src/ui_script/delete_item_tests.rs +++ b/crates/benilla-app/src/ui_script/delete_item_tests.rs @@ -335,9 +335,9 @@ fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() { .unwrap(), "hasEditBox raises the narrow box" ); - assert!( - s.eval::("return StaticPopup1EditBox:HasFocus()") - .unwrap(), + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("StaticPopup1EditBox"), "the entry's OnShow focuses the box, so the player can type straight away" ); assert_eq!( @@ -478,18 +478,19 @@ fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() { fn escape_in_the_typed_confirms_box_is_swallowed_as_the_reference_leaves_it() { let mut s = setup(); drop_in_world(&mut s, 871, "Flurry Axe", 4); - assert!(s - .eval::("return StaticPopup1EditBox:HasFocus()") - .unwrap()); + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("StaticPopup1EditBox") + ); assert!(s.key_input("ESCAPE"), "the focused box consumes ESCAPE"); assert!( s.eval::("return StaticPopup1:IsVisible()").unwrap(), "the dialog stays: the entry names no escape handler" ); - assert!( - s.eval::("return StaticPopup1EditBox:HasFocus()") - .unwrap(), + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("StaticPopup1EditBox"), "and the box keeps focus" ); assert!(s.cursor_item().is_some(), "the item is still on the cursor"); diff --git a/crates/benilla-app/src/ui_script/event_shape_gate.rs b/crates/benilla-app/src/ui_script/event_shape_gate.rs new file mode 100644 index 00000000..eaff3cdf --- /dev/null +++ b/crates/benilla-app/src/ui_script/event_shape_gate.rs @@ -0,0 +1,385 @@ +//! **The event ARGUMENT-shape gate** — `reference/1.12-events.tsv` against what benilla actually +//! pushes at each fire site (decision 2140). +//! +//! Two gates already guard the event seam by NAME. [`super::reference_ui`]'s +//! `every_event_a_chain_file_registers_has_a_producer` asks whether something fires what a stock +//! file listens for (1889); `every_event_we_fire_is_an_event_the_reference_has` asks whether a +//! name we fire is a 1.12 name at all (1883). **Neither has ever looked at the arguments**, and an +//! event name is a plain string at both ends — so a fire with the wrong argument shape is silent +//! on both sides. It is 1842's finding one API over: the right name, the wrong shape. +//! +//! It was not hypothetical. Three live divergences on the first run: +//! +//! * `PLAYERBANKSLOTS_CHANGED` — two producers in the reference with **different** shapes +//! (`0x5ddd6e` argless, `0x4c728d` `"player"`), one shape here. +//! * `UNIT_PET_EXPERIENCE` and `UNIT_PET_TRAINING_POINTS` — argless here, `%s` there. The second +//! one reaches `PetPaperDollFrame_OnEvent` only through its final `elseif ( arg1 == "pet" )` +//! catch-all, so an argless fire reached **nobody**: the stock pet page never repainted its +//! training points off the event at all. +//! +//! ## What is compared, and what the table can and cannot say +//! +//! The oracle is wow-re's two byte-level censuses, vendored by `scripts/gen-reference-events.py`. +//! Its `conf` column carries the gate rule per row, exactly as `1.12-shapes.tsv` does: +//! +//! * `exact` — every producer that reaches the event has a known shape. **This is the column this +//! gate reads.** +//! * `advisory` — a contributing site declares more varargs than its caller pushes (three sites, +//! image-wide). Not gated: a faithful client must not copy a bug that hands Lua undefined +//! values. +//! * `none` — no producer family reaches it in that census. **Not a claim that it has none** (the +//! census's own header says so in capitals), so a `none` row is skipped, never failed. That is +//! where the whole `CHAT_MSG_*` family sits: those reach Lua through a formatted wrapper the +//! two-helper census does not see. +//! +//! ## Two deliberate narrowings +//! +//! **Only a fire whose event name is a literal is measured.** `ui_unit.rs` builds the ten power +//! events with `format!("UNIT_{}", power_token(…))`, and there is no name to key on at the call +//! site. Those are covered one gate over — `reference_ui`'s `CONSTRUCTED` list — and their single +//! `tok()` argument is the same shape all ten share. +//! +//! **Only a literal `ScriptValue` element's KIND is asserted.** An argument built from a variable +//! (`vec![ScriptValue::Int(bag)]` is literal enough — the *variant* is what types it, not the +//! value) is typed by its variant; anything that is not a `ScriptValue::` constructor at all +//! counts toward the arity and matches any kind. Arity is the assertion that catches the bug +//! class; kinds are the free extra. +use std::collections::BTreeMap; + +/// One row of `reference/1.12-events.tsv`. +struct Row { + /// The alternatives, each a per-slot kind list. `%s` -> `s`, `%d`/`%u`/`%f` -> `n`. + shapes: Vec>, + producers: String, +} + +fn table() -> BTreeMap { + let tsv = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../reference/1.12-events.tsv" + ); + let text = std::fs::read_to_string(tsv).expect("reference/1.12-events.tsv"); + let mut out = BTreeMap::new(); + for line in text.lines() { + if line.starts_with('#') || line.starts_with("name\t") { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + // name ids producers arg_formats conf note + if f.len() < 5 || f[4] != "exact" { + continue; + } + let shapes = f[3] + .split('|') + .map(|alt| { + if alt == "()" { + return Vec::new(); + } + alt.split('%') + .skip(1) + .map(|d| match d.chars().next() { + Some('s') => 's', + _ => 'n', + }) + .collect() + }) + .collect(); + out.insert( + f[0].to_string(), + Row { + shapes, + producers: f[2].to_string(), + }, + ); + } + out +} + +/// Split `body` at its top-level commas — `vec![…]`'s elements, with nested calls left alone. +fn elements(body: &str) -> Vec { + let mut out = Vec::new(); + let (mut depth, mut cur, mut in_str, mut esc) = (0i32, String::new(), false, false); + for c in body.chars() { + if in_str { + cur.push(c); + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => { + in_str = true; + cur.push(c); + } + '(' | '[' | '{' => { + depth += 1; + cur.push(c); + } + ')' | ']' | '}' => { + depth -= 1; + cur.push(c); + } + ',' if depth == 0 => { + out.push(std::mem::take(&mut cur)); + } + _ => cur.push(c), + } + } + if !cur.trim().is_empty() { + out.push(cur); + } + out.into_iter() + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + .collect() +} + +/// The kind an argument expression pushes: `s` string, `n` number, `b` boolean, `x` nil, +/// `?` not determinable from the source. +fn kind_of(expr: &str) -> char { + let e = expr.trim_start_matches('&').trim(); + for (variant, k) in [ + ("ScriptValue::Str", 's'), + ("ScriptValue::Int", 'n'), + ("ScriptValue::Number", 'n'), + ("ScriptValue::Bool", 'b'), + ("ScriptValue::Nil", 'x'), + ] { + if e.starts_with(variant) { + return k; + } + } + '?' +} + +/// Every `(event, argument kinds, where)` benilla fires with a literal name. +fn fire_sites() -> Vec<(String, Vec, String)> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root") + .join("crates"); + let mut out = Vec::new(); + let mut stack = vec![root]; + while let Some(dir) = stack.pop() { + for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + if p.extension().is_none_or(|x| x != "rs") { + continue; + } + // A test's own synthetic fires are not a surface we ship — the same rule + // `every_event_we_fire_is_an_event_the_reference_has` holds, plus the inline + // `#[cfg(test)]` tail a file-name rule cannot see. + if p.to_string_lossy().contains("test") { + continue; + } + let whole = std::fs::read_to_string(&p).unwrap_or_default(); + let text = whole + .split_once("#[cfg(test)]") + .map_or(whole.as_str(), |(head, _)| head); + // Split so this file cannot match its own scanner (the 1883 gate's lesson). + for call in [ + concat!("fire_event", "("), + concat!("fire_event_into", "("), + concat!("pending_events.push", "(("), + ] { + let mut from = 0; + while let Some(i) = text[from..].find(call) { + let at = from + i + call.len(); + from = at; + let rest = text[at..].trim_start(); + let rest = rest.strip_prefix("lua,").map_or(rest, str::trim_start); + let Some(body) = rest.strip_prefix('"') else { + continue; + }; + let Some(end) = body.find('"') else { continue }; + let (name, after) = (&body[..end], &body[end + 1..]); + let Some(args) = after.trim_start().strip_prefix(',') else { + continue; + }; + let args = args.trim_start(); + let kinds = if let Some(v) = args.strip_prefix("vec![") { + let mut depth = 1i32; + let mut close = v.len(); + for (k, c) in v.char_indices() { + match c { + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + close = k; + break; + } + } + _ => {} + } + } + elements(&v[..close]).iter().map(|e| kind_of(e)).collect() + } else if args.starts_with("Vec::new()") { + Vec::new() + } else { + // An argument list built elsewhere — arity unknowable from here. + continue; + }; + out.push((name.to_string(), kinds, p.display().to_string())); + } + } + } + } + out +} + +/// **Every event benilla fires must carry the arguments the reference's producer pushes.** +#[test] +fn every_event_we_fire_carries_the_reference_s_arguments() { + let table = table(); + assert!( + table.len() > 250, + "the events table gave only {} gateable rows — it stopped being read", + table.len() + ); + let sites = fire_sites(); + assert!( + sites.len() > 200, + "the walker found only {} literal fire sites — it stopped matching, which would make this \ + gate silently vacuous", + sites.len() + ); + + let mut checked = 0usize; + let mut wrong: Vec = Vec::new(); + for (name, kinds, at) in &sites { + let Some(row) = table.get(name) else { continue }; + checked += 1; + let fits = row.shapes.iter().any(|want| { + want.len() == kinds.len() + && want + .iter() + .zip(kinds) + .all(|(w, g)| *g == '?' || *g == *w || (*w == 'n' && *g == 'n')) + }); + if !fits { + let shapes: Vec = row + .shapes + .iter() + .map(|s| format!("({})", s.iter().collect::())) + .collect(); + wrong.push(format!( + "{name} fired as ({}) from {at} — the reference's {} producer(s) push {}", + kinds.iter().collect::(), + row.producers, + shapes.join(" | ") + )); + } + } + assert!( + checked >= 175, + "only {checked} fires were measurable against the table — coverage collapsed" + ); + wrong.sort(); + wrong.dedup(); + assert!( + wrong.is_empty(), + "{} fire site(s) push the wrong argument shape. An event name is a plain string at both \ + ends, so nothing else in the build can see this:\n {}", + wrong.len(), + wrong.join("\n ") + ); +} + +/// **Every event the reference's unit-field bridge produces must have a producer here.** +/// +/// This is the half that let `UNIT_DYNAMIC_FLAGS` sit unfired for the whole life of the client. +/// [`super::reference_ui::every_event_a_chain_file_registers_has_a_producer`] asks whether +/// anything fires what a **stock chain file** listens for — and *nothing in 1.12 FrameXML +/// registers `UNIT_DYNAMIC_FLAGS`, so the hole was invisible to it by construction. The corpus is +/// where the demand was (`CT_UnitFrames/CT_TargetFrame.xml:200`, `TipBuddy/TipBuddy.lua:17`), and +/// the corpus is not in this repo. +/// +/// So this gate asks the question from the REFERENCE's side instead, over the one family where +/// that is bounded and exact: `0x51bbb0` registers one watch per named unit-window field, so +/// every event id below `0xb6` that has a name in the client's own table is produced by that +/// bridge, always with the unit token. Twenty-five names, one producer function on our side +/// ([`crate::ui_unit::fire_transitions`] and the two pet-stat feeds), and no judgment call about +/// which of them "should" exist — the reference fires all of them. +/// +/// A CENSUS with a declared set, like 1889's: what must not happen is a new name appearing. +#[test] +fn every_unit_field_bridge_event_has_a_producer() { + /// Produced by the reference's bridge, by nothing here. Each needs a reason, and an entry + /// that comes into production fails the gate so the list cannot outlive its fix. + const UNPRODUCED: &[(&str, &str)] = &[( + "UNIT_LOYALTY", + "field 138, the hunter pet's loyalty level. benilla's pet-stat feed (`ui_pet_stats`) \ + reads happiness and training points off `PET_BYTES` but never the loyalty field, and no \ + window it feeds shows a loyalty line — `PetPaperDollFrame` does not register it either. \ + Unbuilt, not wrong: the day the pet page grows the line, this entry goes with it", + )]; + + let table = table(); + let bridge: Vec<&String> = table + .iter() + .filter(|(_, r)| r.producers.contains("unit-field-bridge")) + .map(|(n, _)| n) + .collect(); + assert!( + bridge.len() >= 25, + "only {} bridge events in the table — it stopped being read", + bridge.len() + ); + let fired: std::collections::HashSet = fire_sites() + .into_iter() + .map(|(n, _, _)| n) + .chain( + // The ten power events are built with `format!("UNIT_{}", power_token(…))`, so no + // literal exists to scan for — the same declared family `reference_ui`'s CONSTRUCTED + // list carries, and an entry there is a promise that something fires it. + [ + "MANA", + "RAGE", + "FOCUS", + "ENERGY", + "HAPPINESS", + "MAXMANA", + "MAXRAGE", + "MAXFOCUS", + "MAXENERGY", + "MAXHAPPINESS", + ] + .iter() + .map(|p| format!("UNIT_{p}")), + ) + .collect(); + + let expected: std::collections::HashSet<&str> = UNPRODUCED.iter().map(|(e, _)| *e).collect(); + let missing: Vec<&str> = bridge + .iter() + .map(|n| n.as_str()) + .filter(|n| !fired.contains(*n) && !expected.contains(n)) + .collect(); + assert!( + missing.is_empty(), + "the reference fires these off a named unit descriptor field and benilla fires them \ + nowhere. No stock file need listen for one — the corpus does, and an addon that \ + registers an event nothing produces hears silence with no error anywhere:\n {missing:?}" + ); + let now_fired: Vec<&str> = UNPRODUCED + .iter() + .map(|(e, _)| *e) + .filter(|e| fired.contains(*e)) + .collect(); + assert!( + now_fired.is_empty(), + "these now HAVE a producer — take them out of UNPRODUCED so the list keeps meaning what \ + it says: {now_fired:?}" + ); +} diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 827a5de8..eb1eb2f8 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1980,9 +1980,9 @@ mod clip_plumb_tests { r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) -- screen top 768 -> frame top 668 - frame:SetSize(300, 200) -- frame bottom 300, right 300 + frame:SetWidth(300); frame:SetHeight(200) -- frame bottom 300, right 300 local child = CreateFrame("Frame", "Child") - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) local marker = child:CreateTexture(nil, "ARTWORK") marker:SetTexture(1, 0, 0) -- pathless colored quad: no BLP asset needed @@ -2070,7 +2070,7 @@ mod clip_plumb_tests { r#" local plain = CreateFrame("Frame", "Plain") plain:SetPoint("TOPLEFT", 0, 0) - plain:SetSize(50, 50) + plain:SetWidth(50); plain:SetHeight(50) local m = plain:CreateTexture(nil, "ARTWORK") m:SetTexture(0, 1, 0) m:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) @@ -2134,7 +2134,7 @@ mod align_tests { for i = 1, 2 do local f = CreateFrame("Frame", "F" .. i) f:SetPoint("TOPLEFT", 0, 0) - f:SetSize(50, 50) + f:SetWidth(50); f:SetHeight(50) for j = 1, 2 do local t = f:CreateTexture(nil, "ARTWORK") t:SetTexture(i / 4, j / 4, 0) @@ -2271,7 +2271,7 @@ mod extract_gate_tests { r#" local plain = CreateFrame("Frame", "Plain") plain:SetPoint("TOPLEFT", 0, 0) - plain:SetSize(50, 50) + plain:SetWidth(50); plain:SetHeight(50) marker = plain:CreateTexture(nil, "ARTWORK") marker:SetTexture(1, 0, 0) marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) @@ -2379,11 +2379,11 @@ mod extract_gate_tests { r#" local doll = CreateFrame("PlayerModel", "CharacterModelFrame") doll:SetPoint("TOPLEFT", 0, 0) - doll:SetSize(233, 224) + doll:SetWidth(233); doll:SetHeight(224) doll:SetUnit("player") local stray = CreateFrame("PlayerModel", "SomeAddonsModelPane") stray:SetPoint("TOPLEFT", 300, 0) - stray:SetSize(233, 224) + stray:SetWidth(233); stray:SetHeight(224) stray:SetUnit("player") "#, ); @@ -2501,7 +2501,7 @@ mod extract_gate_tests { r#" local plain = CreateFrame("Frame", "Plain") plain:SetPoint("TOPLEFT", 0, 0) - plain:SetSize(50, 50) + plain:SetWidth(50); plain:SetHeight(50) marker = plain:CreateTexture(nil, "ARTWORK") marker:SetTexture(0, 0, 1) marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) @@ -2560,7 +2560,7 @@ mod extract_gate_tests { r#" local plain = CreateFrame("Frame", "Plain") plain:SetPoint("TOPLEFT", 0, 0) - plain:SetSize(50, 50) + plain:SetWidth(50); plain:SetHeight(50) marker = plain:CreateTexture(nil, "ARTWORK") marker:SetTexture(0, 1, 0) marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) @@ -2587,7 +2587,7 @@ mod extract_gate_tests { local function box(name, x, r, g, b) local f = CreateFrame("Frame", name) f:SetPoint("TOPLEFT", x, 0) - f:SetSize(50, 50) + f:SetWidth(50); f:SetHeight(50) local t = f:CreateTexture(nil, "ARTWORK") t:SetTexture(r, g, b) t:SetAllPoints() diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 8b1d8077..a4916472 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -920,6 +920,11 @@ mod panel_template_tests; #[cfg(test)] mod shape_gate; +/// The event ARGUMENT-shape gate (decision 2140) — the same question one API over: the two event +/// gates in `reference_ui` compare names, and nothing compared what a fire site pushes. +#[cfg(test)] +mod event_shape_gate; + /// The reference's BasicControls.xml — TEXT/message/_ERRORMESSAGE and the ScriptErrors dialog, /// none of which benilla itself calls: every test enters from Lua the way an addon does. #[cfg(test)] diff --git a/crates/benilla-app/src/ui_script/panel_tests.rs b/crates/benilla-app/src/ui_script/panel_tests.rs index f6268065..7a2f5e59 100644 --- a/crates/benilla-app/src/ui_script/panel_tests.rs +++ b/crates/benilla-app/src/ui_script/panel_tests.rs @@ -771,7 +771,7 @@ fn shipped_panel_slot_pushable_promotes_to_center() { r#" local loot = CreateFrame("Frame", "LootFrame") loot:Hide() - loot:SetSize(50, 50) + loot:SetWidth(50); loot:SetHeight(50) ShowUIPanel(loot) "#, ) @@ -1257,8 +1257,8 @@ fn a_dead_player_opens_only_whiledead_windows() { // Stand-ins for two shipped rows: GossipFrame (no whileDead) and QuestLogFrame (whileDead=1). // Bare frames are enough — the guard runs before any seat is chosen. s.run( - r#"local g = CreateFrame("Frame", "GossipFrame") g:SetSize(50, 50) g:Hide() - local q = CreateFrame("Frame", "QuestLogFrame") q:SetSize(50, 50) q:Hide() + r#"local g = CreateFrame("Frame", "GossipFrame") g:SetWidth(50); g:SetHeight(50) g:Hide() + local q = CreateFrame("Frame", "QuestLogFrame") q:SetWidth(50); q:SetHeight(50) q:Hide() ShowUIPanel(GossipFrame)"#, ) .unwrap(); @@ -1321,9 +1321,9 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() { // holds left, TradeFrame (pushable 1) then ARRIVES at center (UIParent.lua l.734-741's // else-arm: the incumbent outranks nobody, the newcomer settles at center). s.run( - r#"local m = CreateFrame("Frame", "OpenMailFrame") m:SetSize(50, 50) - local a = CreateFrame("Frame", "MerchantFrame") a:SetSize(50, 50) a:Hide() - local b = CreateFrame("Frame", "TradeFrame") b:SetSize(50, 50) b:Hide() + r#"local m = CreateFrame("Frame", "OpenMailFrame") m:SetWidth(50); m:SetHeight(50) + local a = CreateFrame("Frame", "MerchantFrame") a:SetWidth(50); a:SetHeight(50) a:Hide() + local b = CreateFrame("Frame", "TradeFrame") b:SetWidth(50); b:SetHeight(50) b:Hide() ShowUIPanel(MerchantFrame) ShowUIPanel(TradeFrame)"#, ) @@ -1344,7 +1344,7 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() { s.run( r#"HideUIPanel(TradeFrame) HideUIPanel(MerchantFrame) OpenMailFrame:Show() - local l = CreateFrame("Frame", "LootFrame") l:SetSize(50, 50) l:Hide() + local l = CreateFrame("Frame", "LootFrame") l:SetWidth(50); l:SetHeight(50) l:Hide() ShowUIPanel(LootFrame) ShowUIPanel(MerchantFrame)"#, ) diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index b670aaaf..0cab2834 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -1296,7 +1296,8 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not } /// The SHARED item tooltip on a quest reward row (the director's "same tooltip as vendor items"): -/// hovering the reward fires the row's OnEnter → `SetItemById` → the ask-once store. First hover +/// hovering the reward fires the row's OnEnter → `SetQuestLogItem` → the shared id-keyed render +/// → the ask-once store. First hover /// (store cold) shows the fallback name line AND records the ask; after the app's push, a re-hover /// renders the full stat head — the identical lines a vendor row/bag slot gets for this item. #[test] diff --git a/crates/benilla-app/src/ui_script/quiver_tests.rs b/crates/benilla-app/src/ui_script/quiver_tests.rs index 320a5686..12a6d5c3 100644 --- a/crates/benilla-app/src/ui_script/quiver_tests.rs +++ b/crates/benilla-app/src/ui_script/quiver_tests.rs @@ -559,7 +559,13 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() { )])) }; let mut memory = FeedMemory::default(); - apply_container_source(&mut s, &mut memory, quiver(200), [0; 10], Vec::new()); + apply_container_source( + &mut s, + &mut memory, + quiver(200), + Default::default(), + Vec::new(), + ); s.fire_event("START_AUTOREPEAT_SPELL", Vec::new()); s.tick(0.1); @@ -568,7 +574,13 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() { // The server tells us the stack is one lighter. That is a fired shot, and the only way the // addon can ever know it. - apply_container_source(&mut s, &mut memory, quiver(199), [0; 10], Vec::new()); + apply_container_source( + &mut s, + &mut memory, + quiver(199), + Default::default(), + Vec::new(), + ); s.tick(0.1); let (_, reloading, reload_left, _) = shot_state(&mut s); diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index 464ff60b..e96f1e1e 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -97,7 +97,7 @@ fn app_frame_ticked(s: &mut UiScript, dt: f32) { fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) { s.run(&format!( r#" - local a = CreateFrame("Button", "{owner}"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "{owner}"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) {func}_n = 0 function {func}() {func}_n = {func}_n + 1 @@ -534,7 +534,7 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() { s.run( r#" FlipOwner = CreateFrame("Button", "FlipOwner"); FlipOwner:SetPoint("CENTER", 0, 0) - FlipOwner:SetSize(10, 10) + FlipOwner:SetWidth(10); FlipOwner:SetHeight(10) flip_n = 0 -- The two shapes a hover alternates between. The trailing `1` on the wrap arm is -- `AddLine`'s positional wrapText flag (the byte-pinned 0x531630 signature) — it is what @@ -606,7 +606,7 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() { r#" for i = 1, 12 do local b = CreateFrame("Button", "SweepOwner" .. i) - b:SetPoint("CENTER", 0, 0); b:SetSize(10, 10) + b:SetPoint("CENTER", 0, 0); b:SetWidth(10); b:SetHeight(10) end sweep_n = 0 -- A different owner AND a different line shape every step: the two halves of a real sweep @@ -679,7 +679,7 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() { r#" for i = 1, 12 do local b = CreateFrame("Button", "BarOwner" .. i) - b:SetPoint("CENTER", 0, 0); b:SetSize(36, 36) + b:SetPoint("CENTER", 0, 0); b:SetWidth(36); b:SetHeight(36) end bar_n = 0 -- `GameTooltip_SetDefaultAnchor`'s body, which is what every action button actually runs: @@ -754,7 +754,7 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() { r#" for i = 1, 12 do local b = CreateFrame("Button", "BagOwner" .. i) - b:SetPoint("CENTER", 0, 0); b:SetSize(37, 37) + b:SetPoint("CENTER", 0, 0); b:SetWidth(37); b:SetHeight(37) end bag_n = 0 function bag_hover() diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index 9f98cdc9..44df337b 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -419,13 +419,37 @@ const WIDGET_PROBES: &[(&str, &str, &str)] = &[ ("0x87abb0", "ColorSelect", "PGColor"), ("0x87b960", "MessageFrame", "PGMessage"), ("0x87b5c0", "ScrollingMessageFrame", "PGScrollMsg"), - ("0x878948", "PlayerModel", "PGModel"), + ("0x878948", "Model", "PGModel"), + // ── The eleven tables this list did NOT cover until 2142 ────────────────────────────────── + // Twelve of the reference's twenty-three widget tables were mapped, so 11 classes — 190 of + // its 481 widget rows, `GameTooltip`'s 46 among them — were skipped by BOTH the arity gate + // and the kind gate, silently and by omission rather than by a stated narrowing. The + // identities are the corpus census's (2142), each read off its table's own distinctive + // members rather than guessed: `SetOwner`/`AddDoubleLine` is GameTooltip's, `CopyFontObject` + // the Font object's, `PingLocation` the Minimap's, `Dress`/`TryOn` DressUpModel's, + // `CycleVariation` TabardModel's, `StartMovie` MovieFrame's, `SetSlot` LootButton's. + ("0x854198", "GameTooltip", "PGTip"), + ("0x84c538", "Minimap", "PGMinimap"), + ("0x84ee40", "TabardModel", "PGTabard"), + ("0x84f190", "DressUpModel", "PGDress"), + // `PlayerModel` is `Model` plus three verbs, so its own table needs its own instance — the + // `0x878948` row above is now the plain `Model` it always was. + ("0x84f1fc", "PlayerModel", "PGPlayerModel"), + ("0x87ab4c", "MovieFrame", "PGMovie"), + ("0x847ce4", "LootButton", "PGLoot"), + // The base map every widget misses into (its 19 names are exactly `script/mod.rs`'s + // `REGION_MAP_METHODS`). It has no class of its own, so it is probed on a Frame — which is + // what "its own class" means for a base: every widget answers it. + ("0x87c9b8", "Frame", "PGRegionBase"), ]; /// The two region classes, which are made by a frame rather than by `CreateFrame`. const REGION_PROBES: &[(&str, &str)] = &[ ("0x87c128", "PGFrame:CreateTexture('PGTex')"), ("0x87c1d8", "PGFrame:CreateFontString('PGFS')"), + // The FONT OBJECT (`CreateFont`), not a FontString — 22 methods of its own, and the third + // thing here that `CreateFrame` cannot make (2142). + ("0x87c7c8", "CreateFont('PGFontObject')"), ]; /// **The widget half of the gate** (decision 1843) — the half that would have caught 1840's @@ -461,7 +485,7 @@ fn every_widget_method_answers_the_reference_s_return_arity() { } } assert!( - made.len() >= 8, + made.len() >= 20, "only {} widget classes could be instantiated — the probe set is broken", made.len() ); @@ -511,7 +535,10 @@ fn every_widget_method_answers_the_reference_s_return_arity() { } assert!( - checked >= 60, + // 121 at 2142, when the probe set went from 12 of the reference's 23 widget tables to + // all 23. The floor it replaces was 60 — set at the coverage of 1843's day, and never + // raised, so eleven whole classes could go unprobed without this number moving. + checked >= 110, "the widget gate measured only {checked} methods — it has stopped covering anything" ); assert!( @@ -788,7 +815,8 @@ fn every_widget_method_answers_the_reference_s_return_kinds() { } assert!( - checked >= 40, + // 119 at 2142 — the same widening. The floor it replaces was 40. + checked >= 110, "the widget kind gate measured only {checked} methods — it has stopped covering anything" ); assert!( diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index f51dddd0..c0991bb9 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -681,7 +681,9 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() { // — so XML comment blocks are tracked across lines and Lua `--` lines are dropped. fn sink_literals(line: &str) -> Vec<&str> { let mut out = Vec::new(); - for sink in [":SetText(", ":SetFormattedText(", ":SetButtonText("] { + // `:SetFormattedText(` used to sit between these two and never could have matched — + // it is not a 1.12 verb, so no shipped script writes it (2142 retired ours). + for sink in [":SetText(", ":SetButtonText("] { let mut rest = line; while let Some(at) = rest.find(sink) { rest = &rest[at + sink.len()..]; diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index 72a00188..9c5a0023 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -31,7 +31,7 @@ use benilla_protocol::messages::TaxiMask; use bevy::prelude::*; -use benilla_ui::script::{ScriptValue, TaxiUiState, UiScript}; +use benilla_ui::script::{TaxiUiState, UiScript}; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; @@ -238,12 +238,14 @@ fn feed_taxi( if fresh != *last || (fresh.is_some() && name_changed) { script.set_taxi(fresh.clone()); match (&*last, &fresh) { - (None, Some(_)) | (Some(_), Some(_)) => script.fire_event( - "TAXIMAP_OPENED", - vec![ScriptValue::Str( - flightmaster_name.clone().unwrap_or_default(), - )], - ), + // **No arguments** — `0x4dba96` is the event's one fire site image-wide and it is a + // `FrameScript_SignalEvent 0x703e50`, `__fastcall(ecx = id)` with a plain `ret` and + // no vararg push at all. The flight master's name we used to pass was an invention: + // `TaxiFrame_OnEvent` reads `UnitName("npc")` for it and never looks at `arg1` + // (decision 2140, found by the argument gate). + (None, Some(_)) | (Some(_), Some(_)) => { + script.fire_event("TAXIMAP_OPENED", Vec::new()); + } (Some(_), None) => script.fire_event("TAXIMAP_CLOSED", vec![]), (None, None) => {} } diff --git a/crates/benilla-app/src/ui_text/mod.rs b/crates/benilla-app/src/ui_text/mod.rs index 40251dd8..d57e7790 100644 --- a/crates/benilla-app/src/ui_text/mod.rs +++ b/crates/benilla-app/src/ui_text/mod.rs @@ -294,7 +294,7 @@ mod ellipsis_memo_tests { r#" local f = CreateFrame("Frame", "MemoHost") f:SetPoint("TOPLEFT", 0, 0) - f:SetSize(100, 100) + f:SetWidth(100); f:SetHeight(100) for i = 1, 8 do local t = f:CreateTexture(nil, "ARTWORK") t:SetTexture(1, 0, 0) diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 7a69e6cd..420445b8 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -966,6 +966,11 @@ pub(crate) fn snapshot( // sets it without being a player. player_controlled: store.0.unit_flags() & 0x8 != 0, flags: store.0.unit_flags(), + // The raw `UNIT_DYNAMIC_FLAGS` dword the event of the same name fires on — the `flags` + // arm above, a hundred descriptor fields over. `tapped`/`tapped_by_player` up the struct + // are two of its bits; this is the whole word, because the reference's watch is a memcmp + // over the dword and not a bit test. + dynamic_flags: store.0.unit_dynamic_flags(), owner: store .0 .unit_summoned_by() @@ -1315,6 +1320,25 @@ pub(crate) fn fire_transitions( if prev.is_some() && changed(|u| u64::from(u.player_flags)) { script.fire_event("PLAYER_FLAGS_CHANGED", vec![tok()]); } + // `UNIT_DYNAMIC_FLAGS` (id 137) — the third arm of the same bridge, and one benilla had + // **never fired at all**, which is a different failure from firing it wrong: an addon that + // registers it hears nothing, forever, and there is no error anywhere to say so. + // + // Nothing in 1.12 FrameXML registers it, which is exactly why no gate saw the hole — the + // producer gate's oracle is what a stock chain file listens for (`reference_ui:: + // every_event_a_chain_file_registers_has_a_producer`), and no stock file listens for this one. + // The corpus does: `CT_UnitFrames/CT_TargetFrame.xml:200` and `TipBuddy/TipBuddy.lua:17` + // register it, and both are repaint wires for the tapped/grey-bar state — the same state + // `UnitIsTapped` publishes and that the `UNIT_FACTION` arm below repaints the stock frames on. + // + // Byte-verified, not inherited (see [`UnitState::dynamic_flags`]): the name-table slot for + // 137 resolves to `"UNIT_DYNAMIC_FLAGS"`, and the watch length is one dword, so the trigger + // is the RAW word and not the four bits this struct decodes. `prev.is_some()` for the same + // reason the two arms above carry it — a unit's first snapshot is its CREATE, and the create + // block runs no notify pass. + if prev.is_some() && changed(|u| u64::from(u.dynamic_flags)) { + script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]); + } // The POWER pair is named per resource in 1.12, not once with the token as arg2: the reference's // `UnitFrameManaBar_Initialize` registers `UNIT_MANA`/`UNIT_RAGE`/`UNIT_FOCUS`/`UNIT_ENERGY`/ // `UNIT_HAPPINESS` and the five `UNIT_MAX*` twins (`UnitFrame.lua:190-199`), and nothing in @@ -2397,6 +2421,60 @@ mod tests { assert!(fired(Some(with(0x1)), with(0x1)).is_empty()); } + /// **`UNIT_DYNAMIC_FLAGS` — an event benilla had never fired at all** (decision 2140). + /// + /// Same bridge, same shape, the third of the three raw-dword arms: id 137 is the unit-window + /// index of descriptor field 143, the name-table slot for 137 has exactly one writer in the + /// image and it points at the string `"UNIT_DYNAMIC_FLAGS"`, and the watch length is 4 — one + /// dword — so the gate is a `repe cmpsb` over the whole word and **any** bit fires it. + /// + /// The control below is bit `0x2` (`UNIT_DYNFLAG_TRACK_UNIT`, Hunter's Mark), which no + /// `UnitState` field decodes: an arm driven off `tapped`/`tapped_by_player` passes every other + /// assertion here and fails that one. + #[test] + fn unit_dynamic_flags_fires_per_token_on_any_bit_and_never_on_first_sight() { + let fired = |prev: Option, cur: UnitState| -> Vec { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("UNIT_DYNAMIC_FLAGS") + f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end) + "#, + ) + .unwrap(); + fire_transitions(&mut s, "target", prev.as_ref(), &cur); + s.eval::>("return SEEN").unwrap() + }; + let with = |dyn_flags: u32| UnitState { + exists: true, + has_object: true, + dynamic_flags: dyn_flags, + tapped: dyn_flags & 0x4 != 0, + tapped_by_player: dyn_flags & 0x8 != 0, + ..Default::default() + }; + + // The bit the corpus registers this event for: `0x4` TAPPED, the grey-bar state + // (`CT_UnitFrames/CT_TargetFrame.xml:200`, `TipBuddy/TipBuddy.lua:17`). + assert_eq!( + fired(Some(with(0)), with(0x4)), + vec!["UNIT_DYNAMIC_FLAGS:target".to_string()], + "arg1 is the unit token, and there is no arg2" + ); + // The control that proves the trigger is the RAW DWORD: a bit this struct never decodes. + assert_eq!( + fired(Some(with(0)), with(0x2)), + vec!["UNIT_DYNAMIC_FLAGS:target".to_string()], + "an undecoded bit still fires it — the watch is a memcmp over the dword" + ); + // The create block runs no notify pass (1098 §4), like both arms beside it. + assert!(fired(None, with(0x4)).is_empty()); + // And the control that stops the rest passing by firing always. + assert!(fired(Some(with(0x4)), with(0x4)).is_empty()); + } + /// **The two SELF-ONLY arms of the same handler, each on its own bits** — the half that was /// over-firing, and the half that was missing (decision 2078). /// diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index f047c231..ea041dd7 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -74,6 +74,30 @@ fn row(sender: &str, subject: &str, was_read: bool, item_id: u32, cod: u32) -> M } } +/// One Linen Cloth in the backpack — the send tab's attachment fixture. `ClickSendMailItemButton` +/// reads the cursor, and `GetSendMailItem` answers off the bag slot it came from, so the row needs +/// a real name and texture for the stock tab to title the letter with. +fn one_linen_backpack() -> benilla_ui::script::ContainerState { + let mut slots = std::collections::HashMap::new(); + slots.insert( + 1, + benilla_ui::script::ContainerSlot { + item_id: 2589, + count: 20, + quality: Some(1), + texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()), + link: Some("|cffffffff|Hitem:2589|h[Linen Cloth]|h|r".into()), + bar_placeable: true, + ..Default::default() + }, + ); + benilla_ui::script::ContainerState { + name: Some("Backpack".into()), + num_slots: 16, + slots, + } +} + /// A one-page inbox (2 mails). fn small_inbox() -> MailState { MailState { @@ -256,7 +280,7 @@ fn a_letter_and_the_centre_occupant_evict_each_other() { // The director's setup: mailbox left, character sheet pushed to centre beside it. s.run( - r#"local c = CreateFrame("Frame", "CharacterFrame") c:SetSize(50, 50) c:Hide() + r#"local c = CreateFrame("Frame", "CharacterFrame") c:SetWidth(50); c:SetHeight(50) c:Hide() ShowUIPanel(CharacterFrame)"#, ) .unwrap(); @@ -745,3 +769,114 @@ fn the_open_letters_ring_icon_is_masked_but_the_inboxs_is_not() { got {mail_icon:?}" ); } + +/// **"I can't even type anything in the mailbox window"** — the director's report, at its cause. +/// +/// The send tab has five edit boxes, and exactly one of them — `SendMailNameEditBox` — carries an +/// XML `` (`SendMailFrame_SendeeAutocomplete`). That handler auto-enables the box for the +/// keyboard walk, and the box is the first-registered keyboard frame in the window, so on every +/// keystroke the walk reached it first and the **base** `CSimpleFrame::OnChar` gate consumed: +/// script present → fire → stop. Nothing downstream of it ever saw a character. The reference +/// cannot do that — `CSimpleEditBox` replaces slot `+0x5c` with `0x77a900`, which asks about focus +/// and declines (`0x77a956`) when another box owns it, and never chains to the base gate at all +/// ([`benilla_ui`]'s `script::keyboard::is_editbox`, decision 2145). +/// +/// The one box that DID work is the assertion's control: the autocomplete box types, once per +/// character, and the four that were dead now type too. +#[test] +fn every_send_tab_box_takes_a_keystroke_not_just_the_one_with_an_onchar() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_ui(&s); + s.fire_event("MAIL_SHOW", vec![]); + s.run("MailFrameTab_OnClick(2)").unwrap(); + s.resolve(); + assert!(s.eval::("return SendMailFrame:IsVisible()").unwrap()); + + // The box whose handler was eating everyone else's keys: it still types, and its OnChar still + // fires exactly once per character (the insert path's fire — never the walk's). + s.run("SendMailNameEditBox:SetText('') SendMailNameEditBox:SetFocus()") + .unwrap(); + s.run("SendMailChars = 0 \ + SendMailNameEditBox:SetScript('OnChar', function() SendMailChars = SendMailChars + 1 end)") + .unwrap(); + for c in ["T", "h", "r"] { + assert!(s.char_input(c), "the focused box consumes"); + } + assert_eq!( + s.eval::("return SendMailNameEditBox:GetText()") + .unwrap(), + "Thr" + ); + assert_eq!( + s.eval::("return SendMailChars").unwrap(), + 3, + "OnChar fires once per character, from the insert — not a second time from the walk" + ); + + // …and the four that took nothing at all. The money boxes are `numeric`, so they get digits. + for (box_name, typed, expect) in [ + ("SendMailSubjectEditBox", ["H", "e", "y"], "Hey"), + ("SendMailBodyEditBox", ["o", "d", "d"], "odd"), + ("SendMailMoneyGold", ["1", "2", "3"], "123"), + ("SendMailMoneySilver", ["4", "5", "5"], "45"), // letters="2" + ] { + s.run(&format!("{box_name}:SetText('') {box_name}:SetFocus()")) + .unwrap(); + for c in typed { + assert!(s.char_input(c), "{box_name} consumes the keystroke"); + } + assert_eq!( + s.eval::(&format!("return {box_name}:GetText()")) + .unwrap(), + expect, + "{box_name} takes what was typed into it" + ); + } + assert!(s.take_errors().is_empty(), "and nothing raised on the way"); +} + +/// **"Sending an item still leaves the subject and the item image in the input"** — the director's +/// second report, at its cause: the compose-tab reset used to fire `MAIL_SEND_SUCCESS` *before* it +/// dropped the attachment, so the stock `SendMailFrame_Reset`'s own tail +/// (`SendMailFrame_Update` → `GetSendMailItem`) painted the just-sent item straight back into the +/// form it had blanked one line earlier. `UiScript::reset_compose_tab` is now `0x4acdc0(1)` whole — +/// zero the globals, THEN tail-fire the three events — so there is no window in which the two +/// disagree (decision 2145). +#[test] +fn the_compose_reset_clears_the_subject_and_the_attachment_together() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_ui(&s); + s.set_container(0, Some(one_linen_backpack())); + s.fire_event("MAIL_SHOW", vec![]); + s.run("MailFrameTab_OnClick(2)").unwrap(); + + // Attach the item the way the player does: pick it up, click the send slot. The stock tab + // names the letter after it, which is the text that used to survive the send. + s.run("PickupContainerItem(0, 1) ClickSendMailItemButton()") + .unwrap(); + s.fire_event("MAIL_SEND_INFO_UPDATE", vec![]); + assert_eq!( + s.eval::("return SendMailSubjectEditBox:GetText()") + .unwrap(), + "Linen Cloth (20)", + "the stock tab titles the letter after its attachment" + ); + + // The send lands. This is the whole edge: one call, and the form is clean when the FrameXML + // handler reads it. + s.reset_compose_tab(); + assert_eq!( + s.eval::("return SendMailSubjectEditBox:GetText()") + .unwrap(), + "", + "the subject does not come back from the attachment that just left" + ); + assert!( + s.eval::("return GetSendMailItem() == nil").unwrap(), + "and the attachment is gone with it" + ); +} diff --git a/crates/benilla-ui/examples/dump_widget_methods.rs b/crates/benilla-ui/examples/dump_widget_methods.rs new file mode 100644 index 00000000..ad0d3dc7 --- /dev/null +++ b/crates/benilla-ui/examples/dump_widget_methods.rs @@ -0,0 +1,18 @@ +//! `dump_widget_methods` — print benilla's widget METHOD surface, asked of a real VM. +//! +//! ```text +//! cargo run -q -p benilla-ui --example dump_widget_methods # classmethod, one per line +//! ``` +//! +//! The sibling of [`dump_globals`](../dump_globals.rs). The measurement itself lives in +//! `benilla_ui::script::widget_method_census` — its module doc is the *why*, and the widget-surface +//! gate (decision 2142) reads the same function, so the instrument you look at by hand and the gate +//! that fails the build can never disagree about what our surface is. +use benilla_ui::script::{widget_method_census, UiScript}; + +fn main() -> mlua::Result<()> { + for (class, method) in widget_method_census(&UiScript::new()?)? { + println!("{class}\t{method}"); + } + Ok(()) +} diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index d4eb9885..d6472706 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -1001,7 +1001,7 @@ mod loader_tests { s.resolve(); s.mouse_button(50.0, 10.0, "LeftButton", true); s.mouse_button(50.0, 10.0, "LeftButton", false); - assert!(s.eval::("return XmlEdit:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name().as_deref(), Some("XmlEdit")); s.run("typed = false").unwrap(); assert!(s.char_input("7")); s.tick(0.0); @@ -1039,8 +1039,7 @@ mod loader_tests { )) .unwrap(); assert_eq!( - s.eval::(&format!("return {name}:HasFocus()")) - .unwrap(), + s.focused_editbox_name().as_deref() == Some(name), want, "{name}: autoFocus should be {want} (absent = the ctor default, ON)", ); diff --git a/crates/benilla-ui/src/script/battlefield_queue.rs b/crates/benilla-ui/src/script/battlefield_queue.rs index 43c69e3e..242be964 100644 --- a/crates/benilla-ui/src/script/battlefield_queue.rs +++ b/crates/benilla-ui/src/script/battlefield_queue.rs @@ -35,7 +35,7 @@ use mlua::{Lua, MultiValue, Value}; -use super::binding_abi::{bool_or_default, number_arg, predicate}; +use super::binding_abi::{bool_or_default, flag, number_arg}; use super::Model; /// One queue slot as the app pushes it — the reference's `0x20`-byte slot (§2.2) plus the map @@ -371,7 +371,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "CanJoinBattlefieldAsGroup", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.battlefield_list.group_queue)) + Ok(flag(model.battlefield_list.group_queue)) })?, )?; diff --git a/crates/benilla-ui/src/script/binding_abi.rs b/crates/benilla-ui/src/script/binding_abi.rs index 124e7b1a..a0691800 100644 --- a/crates/benilla-ui/src/script/binding_abi.rs +++ b/crates/benilla-ui/src/script/binding_abi.rs @@ -272,52 +272,6 @@ fn trim_g(s: &str) -> &str { s.trim_end_matches('0').trim_end_matches('.') } -/// A **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean. -/// -/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12's widget -/// predicates (1830) and its 29 `Unit*` predicates (2043) do not push booleans. `lua_pushboolean -/// 0x6f39f0` has seven call sites in the whole image; **not one is inside a widget registrar body, -/// and no binding in the 83-entry unit table at `0x850438` calls it at all** — those predicates go -/// through `lua_pushnumber 0x6f3810` (the constant double `1.0`) / `lua_pushnil 0x6f37f0`, exactly -/// one value at every live `ret`. -/// -/// **Not a whole-image absolute, and 2048 corrects 2043 for saying so.** `lua_pushboolean` is real -/// and one of its seven callers *is* a registered FrameScript binding: `IsPetAttackActive -/// 0x4be0e0` answers a genuine Lua `true`/`false` and **never nil** (ours matches — see -/// [`super::pet`]). The other six are `rawequal`/`pcall`/`xpcall` in the base table `0x811e28` and -/// two unregistered helpers. So "1.12 has no `lua_pushboolean`" is false; "these two predicate -/// families never reach it" is what the bytes support, and is what this helper encodes. -/// -/// **The false leg is settled per body, never inherited from a neighbour.** `IsEnabled 0x7800b0` -/// is number-`1`/number-`0`-and-never-nil (`setne` + `fild`), which is what makes -/// `FriendsFrame.lua:404`'s `== 0` live code — so it does NOT come through here. Inside the unit -/// table itself the *numeric getters* answer the number `0.0` where the predicates answer `nil` -/// (`UnitLevel 0x518144`, `UnitMana 0x5177c8`, `UnitXP 0x5173f5`, ~15 more; `UnitSex 0x517f9f` -/// pushes `2.0`). -/// -/// **Truthiness hides the difference and direct comparison inverts it**, which is why this survived -/// so long. `if frame:IsVisible()` reads the same either way; `if frame:IsVisible() == nil` does -/// not — under a boolean a hidden frame answers `false`, and `false == nil` is FALSE, so the caller -/// concludes the frame is visible exactly when it is not. The 1.12 addon corpus has 21 such direct -/// comparisons (`IsVisible() == nil` ×9, `GetChecked() == 1` ×6, `IsVisible() ~= nil` ×4, and one -/// each of `~= 1` / `== 1`) across Questie, AtlasQuest, CT_BagMod, MikScrollingBattleText's options -/// and `_dl`. -/// -/// The reference proves its own shape without needing the bytes: stock `UIOptionsFrame.xml:310` -/// saves a checkbox as `SHOW_BUFF_DURATIONS = tostring(this:GetChecked())` and stock -/// `BuffFrame.lua:71` reads it back as `== "1"`. That round-trip only closes if `GetChecked` -/// returns the number 1 — `tostring(true)` is `"true"`, and buff timers would never appear. -/// -/// Adopting it is strictly safer than what it replaces: every `if x` and `not x` site reads -/// identically, and only the direct comparisons change — from wrong to right. -pub(crate) fn predicate(b: bool) -> Value { - if b { - Value::Integer(1) - } else { - Value::Nil - } -} - pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool { let Some(v) = v else { return default; // LUA_TNONE @@ -373,6 +327,52 @@ pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool { /// /// Enforced, not remembered: `ui_script::shape_gate::no_query_binding_answers_a_lua_boolean` /// probes the whole registered query surface and fails on any Lua boolean. +/// +/// ## What `predicate` said, folded in (2142) +/// +/// This helper had a byte-identical TWIN sixty lines up the same file — `predicate`, 49 call +/// sites to this one's 77 — carrying its own half of the evidence. 2118 consolidated five *local* +/// copies into this one and left that one standing, which is the same drift one level up: a house +/// rule with two homes gets re-derived at each new file, and the file that picks the wrong one is +/// invisible against the others. The twin is gone; everything it recorded is below. +/// +/// +/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12's widget +/// predicates (1830) and its 29 `Unit*` predicates (2043) do not push booleans. `lua_pushboolean +/// 0x6f39f0` has seven call sites in the whole image; **not one is inside a widget registrar body, +/// and no binding in the 83-entry unit table at `0x850438` calls it at all** — those predicates go +/// through `lua_pushnumber 0x6f3810` (the constant double `1.0`) / `lua_pushnil 0x6f37f0`, exactly +/// one value at every live `ret`. +/// +/// **Not a whole-image absolute, and 2048 corrects 2043 for saying so.** `lua_pushboolean` is real +/// and one of its seven callers *is* a registered FrameScript binding: `IsPetAttackActive +/// 0x4be0e0` answers a genuine Lua `true`/`false` and **never nil** (ours matches — see +/// [`super::pet`]). The other six are `rawequal`/`pcall`/`xpcall` in the base table `0x811e28` and +/// two unregistered helpers. So "1.12 has no `lua_pushboolean`" is false; "these two predicate +/// families never reach it" is what the bytes support, and is what this helper encodes. +/// +/// **The false leg is settled per body, never inherited from a neighbour.** `IsEnabled 0x7800b0` +/// is number-`1`/number-`0`-and-never-nil (`setne` + `fild`), which is what makes +/// `FriendsFrame.lua:404`'s `== 0` live code — so it does NOT come through here. Inside the unit +/// table itself the *numeric getters* answer the number `0.0` where the predicates answer `nil` +/// (`UnitLevel 0x518144`, `UnitMana 0x5177c8`, `UnitXP 0x5173f5`, ~15 more; `UnitSex 0x517f9f` +/// pushes `2.0`). +/// +/// **Truthiness hides the difference and direct comparison inverts it**, which is why this survived +/// so long. `if frame:IsVisible()` reads the same either way; `if frame:IsVisible() == nil` does +/// not — under a boolean a hidden frame answers `false`, and `false == nil` is FALSE, so the caller +/// concludes the frame is visible exactly when it is not. The 1.12 addon corpus has 21 such direct +/// comparisons (`IsVisible() == nil` ×9, `GetChecked() == 1` ×6, `IsVisible() ~= nil` ×4, and one +/// each of `~= 1` / `== 1`) across Questie, AtlasQuest, CT_BagMod, MikScrollingBattleText's options +/// and `_dl`. +/// +/// The reference proves its own shape without needing the bytes: stock `UIOptionsFrame.xml:310` +/// saves a checkbox as `SHOW_BUFF_DURATIONS = tostring(this:GetChecked())` and stock +/// `BuffFrame.lua:71` reads it back as `== "1"`. That round-trip only closes if `GetChecked` +/// returns the number 1 — `tostring(true)` is `"true"`, and buff timers would never appear. +/// +/// Adopting it is strictly safer than what it replaces: every `if x` and `not x` site reads +/// identically, and only the direct comparisons change — from wrong to right. pub(crate) fn flag(b: bool) -> Value { if b { Value::Integer(1) diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index b4cb86a9..c3dc95e8 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -393,7 +393,11 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { model.region_data.insert( rh, RegionData { - additive: slot == Slot::Highlight, + blend: if slot == Slot::Highlight { + crate::script::BlendMode::Add + } else { + crate::script::BlendMode::default() + }, ..Default::default() }, ); @@ -934,6 +938,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { }, )?, )?; + // GetTextColor() → r, g, b, a — **FOUR values** (`0x781100`, table `0x879d00`, argc 1, arity 4, + // kinds `(number,number,number,number)`), the same shape as the FontString/Font-object twins + // (`0x79d840` table `0x87c1d8`, `0x79f680`) this codebase already answers. Three is the + // plausible wrong answer, and the shapes table calls the name `name-not-unique` precisely + // because it is registered on seven tables; this is the Button one. + // + // **Read like `GetFont` right above, not like `SetTextColor` right above that.** The reference + // reads the colour back off the button's NORMAL embedded `CSimpleFont`, and an unset local + // colour there still resolves through what the normal state INHERITS + // (``/`SetTextFontObject`) — which is how a `GameMenuButtonTemplate` button answers + // `GameFontNormal`'s colour before anything calls `SetTextColor`. Falling back to white would + // be wrong in exactly the case the corpus cares about: reading a stock button's colour to + // restore it after a temporary recolour. White is the last resort, matching every other colour + // getter here (`GetVertexColor`, `FontString:GetTextColor`) — the untinted default a region + // with no colour anywhere draws at. + // + // Completeness rather than a live break: the census found no corpus site with a Button + // receiver (every measured `GetTextColor` call is on a FontString or a font object). It is here + // because the reference registers it and the widget shape gate can then cover it. + m.set( + "GetTextColor", + lua.create_function(|lua, this: Table| { + let (own, inherits) = + with_button(lua, &this, |bs| (bs.normal_color, bs.normal_font.clone()))?; + let c = own.unwrap_or_else(|| { + let model = lua.app_data_ref::().expect("model app_data"); + inherits + .and_then(|n| model.font_object(&n)) + .and_then(|f| f.color) + .unwrap_or([1.0, 1.0, 1.0, 1.0]) + }); + Ok((c[0], c[1], c[2], c[3])) + })?, + )?; m.set( "SetHighlightTextColor", lua.create_function( @@ -1006,9 +1044,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // `guildButtonDisabled` read BOTH spellings and said so); those workarounds go with this // change, which is 1719's bar — a prior call revisited because a new measurement arrived. // - // Slider carries its own `IsEnabled` (slider.rs) and is deliberately NOT changed here: the - // note above is the BUTTON method table, and whether the slider widget shares the contract is - // unverified. Assuming they match is exactly the guess this comment exists to have avoided. + // Slider used to carry its own `IsEnabled` (slider.rs), left alone here because "whether the + // slider widget shares the contract is unverified". It does not share it — it does not HAVE + // one: 1.12 registers `Enable`/`Disable`/`IsEnabled` on this table alone, and the Slider's + // LoadXML `0x789580` has no `enabled` attribute either, so ours was a superset. The trio came + // off the Slider rather than being brought into line with this contract; see the note at its + // old site in `slider.rs`. m.set( "IsEnabled", lua.create_function(|lua, this: Table| { @@ -1144,7 +1185,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetChecked", lua.create_function(|lua, this: Table| { let checked = with_button(lua, &this, |bs| bs.checked)?; - Ok(crate::script::binding_abi::predicate(checked)) + Ok(crate::script::binding_abi::flag(checked)) })?, )?; lua.set_named_registry_value(REG_CHECKBUTTON_METHODS, c)?; diff --git a/crates/benilla-ui/src/script/cursor/drag.rs b/crates/benilla-ui/src/script/cursor/drag.rs index ffee63e2..43fc9f3f 100644 --- a/crates/benilla-ui/src/script/cursor/drag.rs +++ b/crates/benilla-ui/src/script/cursor/drag.rs @@ -137,13 +137,13 @@ mod tests { click_a, click_b = 0, 0 drag_button = nil local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:RegisterForDrag("LeftButton") a:SetScript("OnDragStart", function(self, button) drag_starts = drag_starts + 1; drag_button = button end) a:SetScript("OnDragStop", function(self) drag_stops = drag_stops + 1 end) a:SetScript("OnClick", function(self) click_a = click_a + 1 end) local b = CreateFrame("Frame", "B") - b:SetPoint("BOTTOMLEFT", 400, 0); b:SetSize(400, 600); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true) b:SetScript("OnReceiveDrag", function(self) receives = receives + 1 end) b:SetScript("OnClick", function(self) click_b = click_b + 1 end) "#, @@ -201,7 +201,7 @@ mod tests { r#" clicks = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:RegisterForDrag("LeftButton") a:SetScript("OnClick", function(self) clicks = clicks + 1 end) "#, @@ -223,7 +223,7 @@ mod tests { r#" clicks = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:RegisterForDrag("LeftButton") a:SetScript("OnClick", function(self) clicks = clicks + 1 end) "#, @@ -252,7 +252,7 @@ mod tests { r#" heard = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:RegisterForDrag("LeftButton") local f = CreateFrame("Frame", "Listener") f:RegisterEvent("DELETE_ITEM_CONFIRM") @@ -395,7 +395,7 @@ mod tests { WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints() heard = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) local f = CreateFrame("Frame", "Listener") f:RegisterEvent("DELETE_ITEM_CONFIRM") f:SetScript("OnEvent", function() heard = heard + 1 end) @@ -502,7 +502,7 @@ mod tests { r#" heard = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) local f = CreateFrame("Frame", "Listener") f:RegisterEvent("DELETE_ITEM_CONFIRM") f:SetScript("OnEvent", function() heard = heard + 1 end) @@ -538,7 +538,7 @@ mod tests { r#" heard = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:RegisterForDrag("LeftButton") local f = CreateFrame("Frame", "Listener") f:RegisterEvent("DELETE_ITEM_CONFIRM") @@ -587,7 +587,7 @@ mod tests { r#" stops = 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 100, 100); a:SetSize(200, 100) + a:SetPoint("BOTTOMLEFT", 100, 100); a:SetWidth(200); a:SetHeight(100) a:EnableMouse(true); a:SetMovable(true) a:RegisterForDrag("LeftButton") a:SetScript("OnDragStart", function() this:StartMoving() end) @@ -637,7 +637,7 @@ mod tests { r#" stops, starts = 0, 0 local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 100, 100); a:SetSize(200, 100) + a:SetPoint("BOTTOMLEFT", 100, 100); a:SetWidth(200); a:SetHeight(100) a:EnableMouse(true); a:SetMovable(true) a:RegisterForDrag("LeftButton") a:SetScript("OnDragStart", function() starts = starts + 1; this:StartMoving() end) diff --git a/crates/benilla-ui/src/script/cursor/money.rs b/crates/benilla-ui/src/script/cursor/money.rs index 232569c7..24d0fd64 100644 --- a/crates/benilla-ui/src/script/cursor/money.rs +++ b/crates/benilla-ui/src/script/cursor/money.rs @@ -28,7 +28,7 @@ use mlua::{Lua, Value}; use super::{queue_cursor_update, CursorMoney, CursorPayload}; -use crate::script::binding_abi::{number_arg, predicate}; +use crate::script::binding_abi::{flag, number_arg}; use crate::script::Model; /// The coin icon for an amount of copper — `GetCoinIcon 0x48d4e0`'s table, SIGNED thresholds @@ -161,10 +161,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "CursorHasMoney", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(matches!( - model.cursor, - Some(CursorPayload::Money(_)) - ))) + Ok(flag(matches!(model.cursor, Some(CursorPayload::Money(_))))) })?, )?; g.set( diff --git a/crates/benilla-ui/src/script/dialog_verbs.rs b/crates/benilla-ui/src/script/dialog_verbs.rs index d20cd26c..10d684ef 100644 --- a/crates/benilla-ui/src/script/dialog_verbs.rs +++ b/crates/benilla-ui/src/script/dialog_verbs.rs @@ -21,7 +21,7 @@ use mlua::{Lua, Value}; -use super::binding_abi::{bool_or_default, coerced_number, predicate}; +use super::binding_abi::{bool_or_default, coerced_number, flag}; use super::Model; /// The area spirit healer's aura, `0xA18` — the one spell id `CancelAreaSpiritHeal` cancels, and @@ -172,7 +172,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "IsInMeetingStoneQueue", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.meeting_stone_area != 0)) + Ok(flag(model.meeting_stone_area != 0)) })?, )?; @@ -195,7 +195,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "CheckPetUntrainerDist", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.pet_untrainer_pending)) + Ok(flag(model.pet_untrainer_pending)) })?, )?; // §9: the confirm arm — the latch's guid, the money gate (`ERR_NOT_ENOUGH_MONEY`, no packet) diff --git a/crates/benilla-ui/src/script/editbox/methods.rs b/crates/benilla-ui/src/script/editbox/methods.rs index f8d8c88f..250d3025 100644 --- a/crates/benilla-ui/src/script/editbox/methods.rs +++ b/crates/benilla-ui/src/script/editbox/methods.rs @@ -7,7 +7,6 @@ use mlua::{Lua, Table, Value}; use crate::script::object::frame_handle_of; -use crate::script::Model; use crate::widget::EditBoxState; use super::{ @@ -127,14 +126,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; - m.set( - "HasFocus", - lua.create_function(|lua, this: Table| { - let h = frame_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model app_data"); - Ok(model.focused_editbox == Some(h)) - })?, - )?; + // **No `HasFocus`.** 1.12's EditBox table registers the two setters above and no getter at + // all, and an addon that wants the answer keeps its own flag around `SetFocus`/`ClearFocus` — + // `pfQuest/browser.lua:760` documents exactly that and ships the workaround. Ours was an + // unexplained superset a feature-detecting addon would take the wrong branch on (1188, and + // the census that removed it, 2142). Host-side, the focus cell reads back through + // [`crate::script::UiScript::focused_editbox_name`]. // GetInputLanguage() / ToggleInputLanguage() — `0x799550` / `0x799610`, the edit box's // **IME** language, not the chat language: `ChatEdit_OnInputLanguageChanged` shows diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 61f7953d..7dc0f290 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -32,7 +32,7 @@ fn an_autofocus_box_self_acquires_on_the_first_event() { .unwrap(); // No show transition has run for this box, so nothing owns the keyboard yet. assert!(!s.has_keyboard_focus()); - assert!(!s.eval::("return E:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name(), None); // The first char self-acquires focus AND processes that same event. assert!( @@ -40,7 +40,7 @@ fn an_autofocus_box_self_acquires_on_the_first_event() { "an autoFocus box consumes the acquiring event" ); assert!(s.has_keyboard_focus()); - assert!(s.eval::("return E:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name().as_deref(), Some("E")); assert_eq!(s.eval::("return E:GetText()").unwrap(), "a"); } @@ -62,8 +62,9 @@ fn an_autofocus_box_takes_the_keyboard_when_it_is_shown() { assert!(!s.has_keyboard_focus(), "hidden and unfocused to start"); s.run("E:Show()").unwrap(); - assert!( - s.eval::("return E:HasFocus()").unwrap(), + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("E"), "showing an autoFocus box focuses it", ); @@ -108,18 +109,19 @@ fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() { ) .unwrap(); s.run("LATE:Show()").unwrap(); - assert!( - s.eval::("return HOLDER:HasFocus()").unwrap(), + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("Holder"), "a shown autoFocus box does not steal a focus that is already held", ); - assert!(!s.eval::("return LATE:HasFocus()").unwrap()); // And hiding a box that does NOT hold the keyboard leaves it where it is — `0x77e410`'s own // per-box guard (`cmp ecx,eax; jne ret`), which is what makes the override's unconditional // tail-jmp harmless. s.run("LATE:Hide()").unwrap(); - assert!( - s.eval::("return HOLDER:HasFocus()").unwrap(), + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("Holder"), "hiding an unfocused box does not clear somebody else's focus", ); assert!(s.errors().is_empty(), "{:?}", s.errors()); @@ -179,7 +181,7 @@ fn set_focus_on_hidden_box_is_a_noop() { "#, ) .unwrap(); - assert!(!s.eval::("return E:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name(), None); assert!(!s.has_keyboard_focus()); assert_eq!( s.eval::("return gained").unwrap(), @@ -198,7 +200,7 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine local function wire(name, y) local f = CreateFrame("EditBox", name) f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, y) - f:SetSize(100, 20) + f:SetWidth(100); f:SetHeight(20) f:SetScript("OnEditFocusGained", function() table.insert(log, "gained"..name) end) f:SetScript("OnEditFocusLost", function() table.insert(log, "lost"..name) end) end @@ -212,11 +214,11 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine // Neither box has autoFocus, yet a click focuses each. s.mouse_button(50.0, 10.0, "LeftButton", true); s.mouse_button(50.0, 10.0, "LeftButton", false); - assert!(s.eval::("return A:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name().as_deref(), Some("A")); s.mouse_button(50.0, 110.0, "LeftButton", true); s.mouse_button(50.0, 110.0, "LeftButton", false); - assert!(s.eval::("return B:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name().as_deref(), Some("B")); let log: Vec = s.eval("return log").unwrap(); assert_eq!(log, vec!["gainedA", "lostA", "gainedB"]); @@ -473,8 +475,9 @@ fn enter_escape_tab_space_fire_their_slots() { .unwrap(); assert!(s.key_input("ENTER")); // single-line → OnEnterPressed assert!(s.key_input("ESCAPE")); // fires, does NOT release focus - assert!( - s.eval::("return E:HasFocus()").unwrap(), + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("E"), "ESCAPE keeps focus" ); assert!(s.key_input("TAB")); @@ -1353,7 +1356,7 @@ fn creating_a_box_does_not_focus_it_the_way_showing_one_does() { !s.has_keyboard_focus(), "a box born visible has not been SHOWN, so it takes no keyboard", ); - assert!(!s.eval::("return E:HasFocus()").unwrap()); + assert_eq!(s.focused_editbox_name(), None); assert!(s.errors().is_empty(), "{:?}", s.errors()); } diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index 83bf1767..e7adc4b5 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -46,6 +46,46 @@ pub(super) fn fire_global(lua: &Lua, event: &str, args: &[ScriptValue]) { } i += 1; } + fire_all_event_listeners(lua, event, args); +} + +/// The `RegisterAllEvents()` half of one dispatch: every frame that asked for the whole stream, +/// after the event's own listeners and skipping any frame that was already visited as one of them. +/// +/// **After, and de-duplicated, because that is where such a frame would sit if the registration +/// were expanded.** A frame joins the all-events set later than the frames already listening for a +/// given event, so it takes the tail of that event's list; and a frame holding both an all-events +/// registration and a `RegisterEvent` for *this* event is one listener, not two — `RegisterEvent`'s +/// own `if !list.contains(&h)` is the same rule one level down. +/// +/// Same mid-dispatch discipline as the walk above (`0x703ee8`, decision 1324): the next frame is +/// re-found by position each step, so a handler that unregisters the walk's successor stops the +/// dispatch there rather than robbing it. +pub(super) fn fire_all_event_listeners(lua: &Lua, event: &str, args: &[ScriptValue]) { + let model_mut = || lua.app_data_mut::().expect("model app_data set"); + let mut at = model_mut().all_event_frames.first().copied(); + while let Some(h) = at { + let mut model = model_mut(); + let Some(pos) = model.all_event_frames.iter().position(|&x| x == h) else { + break; + }; + let next = model.all_event_frames.get(pos + 1).copied(); + let already = model + .event_to_frames + .get(event) + .is_some_and(|l| l.contains(&h)); + let id = model.frame_id(h); + drop(model); + if !already { + if let Err(e) = fire_event_handler(lua, id, event, args) { + lua.app_data_mut::() + .expect("model app_data") + .errors + .push(e.to_string()); + } + } + at = next; + } } /// Fire a frame's `OnEvent` (both conventions) with the given args. diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 6923130a..0b9dfda8 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -487,7 +487,11 @@ impl UiScript { color: has_texture .then(|| texture_color(fill, data.vertex_color)) .flatten(), - additive: data.additive, + // ADD is the one blend mode the renderer acts on; the other four of + // the client's `alphaMode` enum are carried on `RegionData::blend` + // (and answered by `GetBlendMode`) but drawn as straight alpha — the + // stated v1 gap, see [`crate::script::BlendMode`]. + additive: data.blend == crate::script::BlendMode::Add, tex_coords: data.tex_coords, circular: data.circular, portrait_unit: data.portrait_unit, diff --git a/crates/benilla-ui/src/script/font.rs b/crates/benilla-ui/src/script/font.rs index f7ed6e2e..79d0ee16 100644 --- a/crates/benilla-ui/src/script/font.rs +++ b/crates/benilla-ui/src/script/font.rs @@ -71,6 +71,7 @@ use mlua::{Lua, Table, Value}; +use super::binding_abi; use super::object::publish_global; use crate::justify; @@ -293,9 +294,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetObjectType", lua.create_function(|_, _this: Table| Ok("Font"))?, )?; + // **`1`/`nil`, never a Lua boolean** (decision 2118's law; 2142 found this copy). The + // reference's Font object has its OWN `IsObjectType` (`0x79fe60`, table `0x87c7c8`) distinct + // from the Region base's (`0x7a1290`, `0x87c9b8`), and the shapes table types both + // `(nil) | (number)`. This one answered a Rust `bool` — the fourth copy of a house rule that + // three neighbours got right, which is exactly the drift 2118 consolidated `flag` to stop, and + // it was invisible until the shape gate learned to probe the Font object at all. + // + // The ARGUMENT contract is a separate question and is not claimed here: `region.rs`'s twin + // documents the reference's `Usage: %s:IsObjectType("TYPE")` raise and its number-stringifying + // arm from a byte read of `0x7a1290`; nobody has read `0x79fe60`'s, so this copy keeps mlua's + // own coercion rather than pretending to that finding. m.set( "IsObjectType", - lua.create_function(|_, (_this, ty): (Table, String)| Ok(ty.eq_ignore_ascii_case("font")))?, + lua.create_function(|_, (_this, ty): (Table, String)| { + Ok(binding_abi::flag(ty.eq_ignore_ascii_case("font"))) + })?, )?; m.set( "GetName", diff --git a/crates/benilla-ui/src/script/keyboard.rs b/crates/benilla-ui/src/script/keyboard.rs index 1d7fef21..583f6ad0 100644 --- a/crates/benilla-ui/src/script/keyboard.rs +++ b/crates/benilla-ui/src/script/keyboard.rs @@ -60,7 +60,7 @@ use mlua::Lua; use super::{event, Model}; -use crate::widget::FrameHandle; +use crate::widget::{FrameHandle, FrameKind}; /// Which channel a dispatch is on — the two walks the reference registers separately. #[derive(Clone, Copy, PartialEq, Eq)] @@ -102,6 +102,31 @@ fn has_script(lua: &Lua, id: u32, name: &str) -> bool { event::has_widget_handler(lua, id, name) } +/// Is `h` a `CSimpleEditBox`? +/// +/// **A box in the walk is asked about FOCUS, never about a script slot.** Its ctor (`0x779ce8`) +/// registers it in the kind-0 *and* kind-1 buckets, and its vtable `0x81c910` **replaces** the base +/// input slots — `+0x5c` → `0x77a900` (char), `+0x60` → `0x77b160` (key-down) — and never chains to +/// them. wow-re `system/ui/scratch/frame-key-script-delivery.md` §3, at the bytes: *"There is no +/// `call 0x76b760` anywhere in it, so the generic `OnChar` slot `+0x180` is unreachable on an +/// editbox."* The override's own tail is the decline this predicate exists to reach: +/// `0x77a952 cmp esi,eax` — I am the focus owner → insert and consume — else +/// `0x77a956 xor eax,eax`, **return 0, and the walk continues past me**. `0x77b160` has the same +/// shape at `0x77b1c7`/`0x77b21e`. +/// +/// Without this, an unfocused box carrying an XML `` ran the base gate and **ate every +/// keystroke aimed at its neighbours**: stock `SendMailNameEditBox` has one +/// (`SendMailFrame_SendeeAutocomplete`) and is the first-registered keyboard frame in the mail +/// window, so the send tab's subject, body and three money boxes took no input at all — the whole +/// window typed dead except the one box the handler is on (decision 2145). +fn is_editbox(lua: &Lua, h: FrameHandle) -> bool { + let model = lua.app_data_ref::().expect("model app_data"); + model + .arena + .frame(h) + .is_some_and(|f| f.kind == FrameKind::EditBox) +} + /// The dispatcher for one channel — [`Channel`]'s walk, gate and fire, in the reference's order. /// /// Returns whether the event was **consumed**, which is what the caller must use to suppress the @@ -129,6 +154,13 @@ fn walk(lua: &Lua, channel: Channel, arg: &str) -> bool { } continue; } + // Someone else holds the focus (or nothing does and this box is not `autoFocus`): the + // override returns 0 and the walk moves on. The base slot is unreachable here — see + // [`is_editbox`]. A no-focus self-acquire still happens, at [`super::editbox::route`], + // when the whole walk declines. + if is_editbox(lua, h) { + continue; + } let id = { let mut model = lua.app_data_mut::().expect("model app_data"); model.frame_id(h) @@ -214,6 +246,11 @@ pub(super) fn frame_key_input(lua: &Lua, key: &str) -> bool { return false; // the box owns this key; its chord path handles it } } + // …and an UNfocused box declines instead of gating on `+0x188`/`+0x190` ([`is_editbox`]), + // so it can never steal a neighbour's BACKSPACE either. + if is_editbox(lua, h) { + continue; + } let id = { let mut model = lua.app_data_mut::().expect("model app_data"); model.frame_id(h) diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index b63cc8b0..3ea50794 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -1432,9 +1432,12 @@ impl UiScript { // The ScrollFrame mechanism (decision 0112): a live ScrollFrame with a live scroll child // overrides the child's own anchors for this solve — `SetScrollChild` pins the child TOPLEFT - // to the scrollframe's TOPLEFT, offset by the live vertical scroll (`(0, vertical)`; XML - // y-positive-up, so a positive offset lifts the child, bringing content below the fold into - // view — `frame top 500, vertical 40 ⇒ child top 540`). This is a LOCAL map, consulted only + // to the scrollframe's TOPLEFT, offset by the two live scroll offsets + // (`(horizontal, vertical)`, both RAW and unnegated, exactly as the reference's re-anchor + // `0x787100` hands them to `SetPoint`). XML y-positive-up, so a positive vertical lifts the + // child, bringing content below the fold into view — `frame top 500, vertical 40 ⇒ child top + // 540`; x grows right, so a positive horizontal pushes the child right and it is a NEGATIVE + // horizontal that scrolls right (see `ScrollFrameState::horizontal`). This is a LOCAL map, consulted only // while building each round's graph below — the child's authored `LayoutInput.anchors` are // never touched, so `SetScrollChild(nil)` needs no restore: the override just stops being // computed. The child's own width/height (from its own `LayoutInput`) stay whatever they are; @@ -1456,7 +1459,13 @@ impl UiScript { } scroll_child_anchor.insert( child, - Anchor::new(Point::TopLeft, id, Point::TopLeft, 0.0, state.vertical), + Anchor::new( + Point::TopLeft, + id, + Point::TopLeft, + state.horizontal, + state.vertical, + ), ); } pre.scroll = pre.lap(); diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index e74960cf..8debb4a7 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -226,7 +226,7 @@ impl super::UiScript { /// Drain the `SendMail` intent, if one was queued since the last drain — folds in the last /// `SetSendMailMoney`/`SetSendMailCOD` amounts and the attached item's `(bag, slot)`. The - /// attachment is left in place (a failed send keeps it; [`Self::clear_send_mail_item`] drops it + /// attachment is left in place (a failed send keeps it; [`Self::reset_compose_tab`] drops it /// on success). pub fn take_mail_send(&mut self) -> Option { let mut model = self.model_mut(); @@ -243,13 +243,37 @@ impl super::UiScript { }) } - /// Drop the send tab's attached item + money/COD amounts — the app calls this on - /// `MAIL_SEND_SUCCESS` (the reference `SendMailFrame_Reset`, MailFrame.lua l.49/552). - pub fn clear_send_mail_item(&mut self) { - let mut model = self.model_mut(); - model.mail_send_item = None; - model.mail_send_money = 0; - model.mail_send_cod = 0; + /// **`0x4acdc0(1)` — the client's compose-tab reset**, whole. Zeroes the send tab's attachment + /// (`0xb6ef90/94`), money (`0xb6efa4`) and COD (`0xb6efa8`) globals and then **tail-fires its + /// three events**, in this order: `SEND_MAIL_MONEY_CHANGED`, `SEND_MAIL_COD_CHANGED`, + /// `MAIL_SEND_SUCCESS` (`@0x4ace14/1e/28` — wow-re `system/ui/scratch/mail-interaction.md` + /// §1/§4). + /// + /// **The fire belongs to the reset, and that is the whole point of this shape.** Both call + /// sites used to fire `MAIL_SEND_SUCCESS` themselves, and the send-result one fired it + /// *before* clearing — so the stock `SendMailFrame_Reset` ran while `GetSendMailItem()` still + /// answered with the item that had just been sent, and its own `SendMailFrame_Update()` tail + /// put the item's name straight back into the subject box and its texture back on + /// `SendMailPackageButton`. Nothing re-ran the update after the clear landed, so a sent letter + /// left its subject and its icon sitting in the form (director's report, decision 2145). + /// + /// `MAIL_SEND_SUCCESS` is **overloaded** — it means "the compose form is now clean", not "a + /// mail was sent" (the anomaly wow-re verified twice: opening a mailbox fires it too). + pub fn reset_compose_tab(&mut self) { + { + let mut model = self.model_mut(); + model.mail_send_item = None; + model.mail_send_money = 0; + model.mail_send_cod = 0; + } + // Fired here, immediately, rather than queued on `pending_events`: this is a host-side + // edge (the mail system driving the VM), not a Lua binding queueing work for the next + // tick, and its order against the caller's own `MAIL_SHOW`/`MAIL_FAILED` is the law. + // Spelled out one call each — the three are a fixed sequence at three addresses, and the + // chain-file event census (`ui_script::reference_ui`) reads producers as literals. + self.fire_event("SEND_MAIL_MONEY_CHANGED", Vec::new()); // 0x4ace14 + self.fire_event("SEND_MAIL_COD_CHANGED", Vec::new()); // 0x4ace1e + self.fire_event("MAIL_SEND_SUCCESS", Vec::new()); // 0x4ace28 } /// Drop the attachment alone — `SendMail`'s attached-item-gone abort (`ERR_ITEM_NOT_FOUND`, no @@ -1223,7 +1247,7 @@ mod tests { })); s.run("ClickSendMailItemButton()").unwrap(); s.run("SetSendMailMoney(99) SetSendMailCOD(5)").unwrap(); - s.clear_send_mail_item(); + s.reset_compose_tab(); select_default_stationery(&mut s); s.run("SendMail('x','y','z')").unwrap(); let req = s.take_mail_send().unwrap(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 59d4515a..df0b45a8 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -89,6 +89,11 @@ mod handler_prof; mod screenshot; mod tabard; pub use handler_prof::HandlerRow; + +/// The widget-method surface measurement — shared by the `dump_widget_methods` example and the +/// widget-surface gate (decision 2142). +mod surface; +pub use surface::widget_method_census; mod inspect; mod item_stats; mod item_text; @@ -262,9 +267,9 @@ pub use trainer::{ TrainerState, TrainerTooltip, TRAINER_GROUP_KNOWN, }; pub use types::{ - EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow, - Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, - ScriptValue, TexCoords, + BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, + FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, + QuadContent, ScriptValue, TexCoords, }; pub(crate) use types::{FontExplicit, MeasuredText, RegionData}; pub use unit::{ @@ -336,11 +341,20 @@ pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [ /// has `SetGradientAlpha` where FontString has `SetAlphaGradient`: a near-miss pair, and we install /// only the FontString one. /// -/// The tail three are OURS, not 1.12's, and are parked here rather than pruned: `SetPortraitToTexture` -/// and `SetRotation` are texture verbs the carve's 22 does not list, and `SetSize` is an Era -/// geometry verb absent from the Region map. Removing a superset is a separate question per name — -/// this landing partitions, it does not prune. -pub(crate) const TEXTURE_ONLY_METHODS: [&str; 11] = [ +/// **Every name here is the client's own now.** Three of ours were parked in this list pending a +/// per-name check, and all three are gone: `SetRotation` (1.12 registers it only on PlayerModel, +/// `0x84f1fc`/`0x505f00`, and the world-map arrow that justified it is a Model frame now — see +/// `region/paint.rs` at its old site), `SetPortraitToTexture` (1.12 has it as an engine GLOBAL, +/// never a Texture method — `region.rs` registers it there, and a test pins the absence), and +/// `SetSize`, the Era geometry verb that was in neither client map and that nothing outside our +/// own test scaffolding called (decision 2142's census). The 1244/1245 landing partitioned rather +/// than pruned and said the pruning was a separate question per name; this is the answer to three +/// of them. +/// +/// Going the other way: `GetBlendMode`, `SetTexCoordModifiesRect` and `GetTexCoordModifiesRect` +/// joined the list, all three of them in the client's Texture map `0x87c128` (`0x79a890` / +/// `0x79c080` / `0x79c120`), and all three of them missing here until then. +pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [ "SetGradient", "SetGradientAlpha", "GetTexture", @@ -348,22 +362,22 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 11] = [ "GetTexCoord", "SetTexCoord", "SetBlendMode", + "GetBlendMode", + "SetTexCoordModifiesRect", + "GetTexCoordModifiesRect", "SetDesaturated", "GetVertexColor", - "SetRotation", - "SetSize", ]; /// **FontString-only** — the font/text/justify/shadow block plus the string metrics. /// -/// The tail two are OURS: `SetFormattedText` is not in the client's 32, and `SetSize` is the same -/// Era geometry verb the Texture list carries. Parked, not pruned, pending their own checks. -/// -/// `GetStringHeight` was here and is GONE (1251's first prune): byte-verified absent from 1.12 in -/// every encoding, ours was a byte-identical duplicate of `GetHeight`, and every call site — two of -/// our own XML files, two tests, and `Button:GetTextHeight`'s delegate — now goes through the -/// Region method the reference itself uses. -pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 23] = [ +/// **Every name here is the client's own now.** Three of ours were parked in this list: +/// `GetStringHeight` went first (1251 — byte-verified absent, and ours was a byte-identical +/// duplicate of `GetHeight`), and `SetFormattedText` (not among the client's 32) and +/// `SetSize` (the Era geometry verb the Texture list carried too) went with 2142's census, which +/// found neither in the stock chain nor in either addon corpus. The era spelling of +/// `SetFormattedText` is `SetText(format(fmt, ...))`; of `SetSize`, `SetWidth` + `SetHeight`. +pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 21] = [ "SetFont", "GetFont", "SetFontObject", @@ -385,8 +399,6 @@ pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 23] = [ "SetNonSpaceWrap", "CanNonSpaceWrap", "SetAlphaGradient", - "SetFormattedText", - "SetSize", ]; pub(crate) const REGION_MAP_METHODS: [&str; 19] = [ @@ -426,7 +438,10 @@ pub const SCREEN: crate::layout::Handle = 0; /// each kind dispatching to its own value-changed slot. The eight /// `On*Pressed`/text/focus slots are the EditBox's specialized scripts (RF-0082 §2): a focused EditBox /// fires ONLY these, never generic `OnKeyDown`/`OnChar` (its C++ override replaces those slots). -/// `OnVerticalScroll`/`OnScrollRangeChanged` are the ScrollFrame's own slots (decision 0112). +/// `OnHorizontalScroll`/`OnVerticalScroll`/`OnScrollRangeChanged` are the ScrollFrame's own slots +/// (decision 0112; the reference's `[+0x32c]`/`[+0x334]`/`[+0x33c]`, script-name map `0x786c40`). +/// `OnHorizontalScroll` joined the other two with the horizontal offset pair — it is fired by +/// `SetHorizontalScroll`, which is what earns it the row below. /// `OnDragStart`/`OnDragStop`/`OnReceiveDrag` are the drag trio (decision 0216 §3) — driven by /// `RegisterForDrag` + the same mouse path as the six mouse handlers above, not a separate one. /// `OnColorSelect` is the ColorSelect's own slot (RF-28 `+0x338`), fired by its `SetColorRGB`. @@ -451,7 +466,7 @@ pub const SCREEN: crate::layout::Handle = 0; /// corpus call sites across 91 addons); it removes working behaviour from the 23 sites that remain, /// so it is still a change to make deliberately rather than as a side effect of widening this list /// — but the FrameXML half of "with FrameXML fixed first" is most of the way there now. -const SCRIPT_KINDS: [&str; 38] = [ +const SCRIPT_KINDS: [&str; 39] = [ "OnLoad", "OnEvent", "OnUpdate", @@ -482,6 +497,7 @@ const SCRIPT_KINDS: [&str; 38] = [ "OnCursorChanged", "OnEditFocusGained", "OnEditFocusLost", + "OnHorizontalScroll", "OnVerticalScroll", "OnScrollRangeChanged", "OnDragStart", @@ -1370,6 +1386,21 @@ impl UiScript { .is_some_and(|h| model.arena.frame(h).is_some_and(|f| f.effective_visible)) } + /// **Which** EditBox holds the focus, by name — [`Self::has_keyboard_focus`] answers + /// *whether*. Unfiltered by visibility, because the focus cell itself is: a box that hides + /// while focused keeps the cell until something clears it, and the tests that watch the + /// hand-off between two boxes are watching exactly that cell. + /// + /// This is the host-side read of `Model::focused_editbox`. There is no Lua verb for it and + /// there must not be: 1.12's EditBox table has `SetFocus`/`ClearFocus` and no getter, which + /// is why `pfQuest/browser.lua:760` ships its own focus flag rather than asking (decision + /// 2142). + pub fn focused_editbox_name(&self) -> Option { + let model = self.model_ref(); + let h = model.focused_editbox?; + model.arena.frame(h)?.name.clone() + } + /// How many resolves the layout change gate has let through (`Model::layout_solves`) — the /// gate's effectiveness, readable by tests and the app's cost meters (a per-frame delta of 0 /// means the fingerprint judged the frame quiet). diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 872a6d6c..90ac8f14 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -132,7 +132,7 @@ pub(crate) struct Model { /// The frame arena (create/destroy + show/hide/strata/level/scale/alpha propagation). pub(crate) arena: WidgetArena, /// Per-frame layout input (anchors/size/scale). Every live frame has one (created at - /// `CreateFrame`); `SetPoint`/`SetSize`/… mutate it; `resolve` runs the graph over them. + /// `CreateFrame`); `SetPoint`/`SetWidth`/… mutate it; `resolve` runs the graph over them. pub(crate) layout_inputs: HashMap, /// The last [`UiScript::resolve`] result: each resolvable frame's rect. Empty until `resolve`. pub(crate) resolved: HashMap, @@ -374,6 +374,20 @@ pub(crate) struct Model { pub(crate) event_to_frames: HashMap>, /// `frame → its registered events` (for UnregisterEvent / cleanup). pub(crate) frame_events: HashMap>, + /// The frames registered for **every** event (`RegisterAllEvents`), in registration order — + /// the same ordered-Vec-never-a-set discipline as [`Self::event_to_frames`], and dispatched + /// after it for the same reason: a frame that asks for all events asks *after* the frames + /// already listening for a given one, so it takes the tail of that event's list. + /// + /// **A flag rather than an expansion.** 1.12's event ids run to `0x225` = 549 + /// (`FrameScript_InitEvents`), so writing the frame into every per-event list on registration + /// would mean 549 vector pushes per call and 549 scans per `UnregisterAllEvents` — and would + /// also invent a name list this engine has no business owning (ours registers by string, and + /// an event the server sends that no name list knows about would be silently excluded). + /// `UnregisterAllEvents` empties this alongside the per-event registrations, which is the half + /// AceEvent-2.0 depends on: it calls `frame:UnregisterAllEvents()` and then re-registers each + /// individual event it still wants. + pub(crate) all_event_frames: Vec, /// The EditBox that currently owns keyboard focus — the engine's twin of the client's /// class-owned focus global `DAT_00cf4dc8` (`CSimpleEditBox* E`, 0 = none; RF-0082 §1). A focused @@ -1828,6 +1842,7 @@ impl Model { dirty_editboxes: Vec::new(), event_to_frames: HashMap::new(), frame_events: HashMap::new(), + all_event_frames: Vec::new(), focused_editbox: None, mouseover: None, hover_repick: false, diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index 8a9221e3..f5bd2435 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -148,7 +148,7 @@ impl Model { } } - /// A size was AUTHORED on `h` (`SetWidth`/`SetHeight`/`SetSize`, the XML `` through + /// A size was AUTHORED on `h` (`SetWidth`/`SetHeight`, the XML `` through /// them): an implicit rect no longer applies to it, for good — the reference's override /// yields to any authored size. pub(crate) fn note_authored_size(&mut self, h: FrameHandle) { diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index fb8bcf1b..7ab311c1 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -47,6 +47,37 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Ok(()) })?, )?; + // `RegisterAllEvents()` — this frame's `OnEvent` receives EVERY event that is dispatched + // (`0x774c20`, table `0x878ec0`, argc 1, arity 0). Cleared by `UnregisterAllEvents` below, and + // by nothing else: there is no `UnregisterAllEvents`-less way back out, which is exactly how + // the reference's pair reads. + // + // **Latent, not live, and the widest-carried gap the corpus census found.** Nothing in either + // addon corpus calls it *today*, but 63 vanilla addons and 6 of the top 20 ship + // **AceEvent-2.0**, whose `AceEvent:RegisterAllEvents(handler)` runs + // `AceEvent.frame:RegisterAllEvents()` the first time any addon asks for all events — so the + // first one that does raised here. AceEvent is also why the *clearing* half is load-bearing + // rather than tidy: while all-events is on it deliberately stops calling + // `frame:UnregisterEvent(event)` at all, and `AceEvent:UnregisterAllEvents()` gets back to a + // per-event registration by calling `frame:UnregisterAllEvents()` and then re-registering each + // event it still wants. A `RegisterAllEvents` that outlived that call would leave every Ace2 + // addon on the whole event stream forever. + // + // Registered as a FLAG (the frame joins `Model::all_event_frames`), never as an expansion into + // a name list — see that field for why, and `tick::fire_event_into` for the dispatch. + m.set( + "RegisterAllEvents", + lua.create_function(|lua, this: Table| { + let h = frame_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + // Registration order, and re-registering keeps the original position — the same law + // `RegisterEvent` above holds, for the same reason. + if !model.all_event_frames.contains(&h) { + model.all_event_frames.push(h); + } + Ok(()) + })?, + )?; // `UnregisterAllEvents()` — drop every registration this frame holds, in one call. // // 10 corpus addons stop on it (decision 1195), and the idiom is why: an addon's "disable me" @@ -67,6 +98,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { list.retain(|x| x != &h); } } + // …and the all-events registration, which is a registration like any other. See + // `RegisterAllEvents` above: AceEvent-2.0's own re-registration path depends on this. + model.all_event_frames.retain(|x| x != &h); Ok(()) })?, )?; @@ -320,12 +354,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { /// * **`OnAttributeChanged`** (1 site, `Roid-Macros`) — **raising, permanently.** It is 2.0's secure /// frame/attribute system; there is no such slot in any 1.12 resolver. That addon is asking for a /// later client and should hear so. -/// * **`OnHorizontalScroll` · `OnHyperlinkEnter` · `OnHyperlinkLeave` · `OnMessageScrollChanged` · +/// * **`OnHyperlinkEnter` · `OnHyperlinkLeave` · `OnMessageScrollChanged` · /// `OnMovieFinished`/`ShowSubtitle`/`HideSubtitle` · `OnInputLanguageChanged`** — **raising.** /// Real 1.12 slots that we do not fire, and measured at **zero** call sites across the /// 218-addon corpus, so there is nothing to weigh against the trap: they land when their -/// mechanism does (horizontal scroll isn't modeled at all — see -/// [`crate::script::scrollframe`]'s module doc). +/// mechanism does. +/// * **`OnHorizontalScroll`** — **accepted, because `SetHorizontalScroll` fires it.** It was on +/// the line above while "horizontal scroll isn't modeled at all", which stopped being true when +/// the ScrollFrame's horizontal offset pair landed beside the vertical one +/// ([`crate::script::scrollframe`]); `aux-addon` reads and writes both axes. /// * **`OnUpdateModel` · `OnAnimFinished`** — **accepted since decision 2007**, because the tick /// fires them: the model pane's scene clock runs in this engine now (`tick.rs`), `OnUpdateModel` /// at the top of every paint of a visible pane with a file (`0x76d1a0`) and `OnAnimFinished` diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 69ae7c81..a1850fa7 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -14,7 +14,10 @@ use super::{decode_id, draw_layer_from_str, frame_handle_of, frame_wrapper, stra /// Populate `m`'s visibility/hierarchy/strata/backdrop/mouse methods (see the module doc). pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { - // Show / Hide / visibility + // Show / Hide / visibility. **`SetShown` is not here and must not come back**: the branchless + // setter belongs to a later expansion — no 1.12 method table registers it, and neither the + // stock chain nor either addon corpus calls it (decision 2142's census). A frame is shown or + // hidden by the two verbs the era has. m.set( "Show", lua.create_function(|lua, this: Table| set_shown(lua, &this, true))?, @@ -23,21 +26,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { "Hide", lua.create_function(|lua, this: Table| set_shown(lua, &this, false))?, )?; - // SetShown(bool) — the live API's branchless Show/Hide (a consensus call across the 0068 - // target addons; Lua truthiness, so SetShown(nil) hides). - m.set( - "SetShown", - lua.create_function(|lua, (this, shown): (Table, Value)| { - let show = !matches!(shown, Value::Nil | Value::Boolean(false)); - set_shown(lua, &this, show) - })?, - )?; m.set( "IsShown", lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model.arena.frame(h).map(|f| f.shown).unwrap_or(false), )) })?, @@ -47,7 +41,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model .arena .frame(h) @@ -759,7 +753,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model.arena.is_mouse_enabled(h), )) })?, @@ -795,7 +789,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model.arena.is_keyboard_enabled(h), )) })?, @@ -863,7 +857,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model.arena.is_mouse_wheel_enabled(h), )) })?, @@ -889,7 +883,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model.arena.is_clamped_to_screen(h), )) })?, diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index 2d95d4e7..98886efe 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -1,6 +1,6 @@ //! Frame method-table cluster: anchoring and size — `SetPoint`/`ClearAllPoints`/`GetPoint`/ -//! `SetAllPoints`/`SetWidth`/`SetHeight`/`SetSize`/`GetWidth`/`GetHeight` and the resolved-edge -//! readers (`GetLeft`/`GetRight`/`GetTop`/`GetBottom`). Split out of [`super`] purely for size — see +//! `SetAllPoints`/`SetWidth`/`SetHeight`/`GetWidth`/`GetHeight` and the resolved-edge readers +//! (`GetLeft`/`GetRight`/`GetTop`/`GetBottom`). Split out of [`super`] purely for size — see //! its module doc for the shared id/handle plumbing and method-table wiring. use mlua::{Lua, Table, Value}; @@ -41,7 +41,7 @@ fn prefetch_relative_to( /// Populate `m`'s layout (anchor/size) methods (see the module doc). pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { - // Layout: SetPoint / ClearAllPoints / SetSize / SetWidth / SetHeight / GetWidth / GetHeight + // Layout: SetPoint / ClearAllPoints / SetWidth / SetHeight / GetWidth / GetHeight m.set( "SetPoint", lua.create_function( @@ -209,23 +209,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Ok(()) })?, )?; - m.set( - "SetSize", - lua.create_function(|lua, (this, w, ht): (Table, f32, f32)| { - let h = frame_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let input = model.layout_inputs.entry(h).or_default(); - let changed = - input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits(); - input.width = w; - input.height = ht; - if changed { - model.touch_layout_frame(h); - } - model.note_authored_size(h); - Ok(()) - })?, - )?; + // **No `SetSize`.** It is an Era geometry verb, in neither the Frame nor the Region method + // table of 1.12 — and neither the stock chain nor either addon corpus writes it (decision + // 2142's census). The two setters above are the era's whole size surface. m.set( "GetWidth", lua.create_function(|lua, this: Table| { @@ -370,7 +356,7 @@ pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 { /// `GetWidth`/`GetHeight`: the resolved rect's span in LOCAL units (screen ÷ effective scale — the /// client returns the value as authored, and `SetWidth(w)` on a scaled frame resolves to `w·scale` /// screen px) if `resolve` has produced one, else the explicit size the frame was given -/// (`SetWidth`/`SetHeight`/`SetSize`, already local) — matching the client's "0 = derive". +/// (`SetWidth`/`SetHeight`, already local) — matching the client's "0 = derive". fn size_read(model: &Model, h: FrameHandle, width: bool) -> f32 { if let Some(r) = model.resolved.get(&h) { let span = if width { r.width() } else { r.height() }; diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index 353966ae..7bfb3fe1 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -405,11 +405,11 @@ fn set_flag(lua: &Lua, this: &Table, which: Flag, value: bool) -> mlua::Result<( } /// `IsMovable`/`IsResizable`/`IsUserPlaced` — the NUMBER 1 or nil, never a boolean (1830, -/// [`crate::script::binding_abi::predicate`]). +/// [`crate::script::binding_abi::flag`]). fn get_flag(lua: &Lua, this: &Table, which: Flag) -> mlua::Result { let h = frame_handle_of(lua, this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( model.arena.frame(h).is_some_and(|f| match which { Flag::Movable => f.movable, Flag::Resizable => f.resizable, diff --git a/crates/benilla-ui/src/script/object/toplevel.rs b/crates/benilla-ui/src/script/object/toplevel.rs index 0bc77dd1..8aec43bf 100644 --- a/crates/benilla-ui/src/script/object/toplevel.rs +++ b/crates/benilla-ui/src/script/object/toplevel.rs @@ -132,9 +132,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( - model.arena.is_toplevel(h), - )) + Ok(crate::script::binding_abi::flag(model.arena.is_toplevel(h))) })?, )?; // Raise() — `0x775a50` → `0x76a5b0` → `0x7650f0(this, force = 1)`. Note it is legal on any diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index d4d5a71c..470c7ce7 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -484,11 +484,13 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { // ours was ONE table for both, so a Texture answered `SetText` and a FontString answered // `SetTexture`: a superset in both directions. // - // **Partitioned, not pruned.** Every name we install keeps a home; what changes is which leaf - // can see it. Removing the five names that are in NEITHER client map (`SetPortraitToTexture`, - // `SetRotation`, `SetSize`, `SetFormattedText`, `GetStringHeight`) is a separate question per - // name — and getting a split wrong REMOVES verbs addons use, which is worse than the superset - // it fixes. + // **Partitioned first, then pruned name by name.** The 1244/1245 split only decided which + // leaf could SEE each name, because getting a split wrong REMOVES verbs addons use, which is + // worse than the superset it fixes. The five that were in NEITHER client map were left in + // place as a separate question per name, and all five have since been answered: + // `GetStringHeight` (1251), `SetRotation` and `SetPortraitToTexture` (the latter a 1.12 + // GLOBAL, and it lives there now), and `SetFormattedText` and `SetSize` (2142). Both leaves + // are the client's own lists now. // // Copied out of the full table rather than installed twice, so one implementation stands behind // both visibilities — and note the carve's warning that the shared names use the IDENTICAL @@ -892,7 +894,8 @@ pub(super) fn region_set_point( /// Two details of the FontString row are worth spelling out, because both were wrong here before /// and neither is guessable from the name: /// -/// * **The authored value WINS.** The old code preferred the measure and fell back to `SetSize`; +/// * **The authored value WINS.** The old code preferred the measure and fell back to the +/// authored size; /// the reference's `jp` at `0x77294a` skips the measure entirely when the authored value is /// non-zero. Per axis, not per region — `` takes 290 from the author and /// the height from the text. diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index ced950e9..df575d1e 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -56,25 +56,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - m.set( - "SetSize", - lua.create_function(|lua, (this, w, h): (Table, f32, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let new = Some((w, h)); - let changed = !size_bits_eq(d.size, new); - d.size = new; - if changed { - // A size write moves no edge and no roster membership (decision 1388) — and on - // a FontString the width is the WRAP width, a measure-key input, so it names - // itself on the measure ledger too. - model.touch_layout_region(rh); - model.touch_measure(rh); - } - Ok(()) - })?, - )?; + // **No `SetSize`** — the frame twin's note in `object/layout_methods.rs` applies here + // unchanged: an Era verb 1.12's Region map does not carry (decision 2142). m.set( "GetWidth", diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index b930ab6a..205d0498 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -4,7 +4,7 @@ use mlua::{Lua, MultiValue, Table, Value}; use crate::script::object::{as_f32, draw_layer_from_str, draw_layer_name}; -use crate::script::{Model, TexCoords}; +use crate::script::{BlendMode, Model, TexCoords}; /// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. use super::region_handle_of; @@ -411,17 +411,97 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - // SetBlendMode("BLEND"|"ADD"|…) — the shared alphaMode enum (0x811aa8); only ADD changes - // draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as straight alpha, a stated gap). + // SetBlendMode("DISABLE"|"ALPHAKEY"|"BLEND"|"ADD"|"MOD") — `0x79a950`, the shared alphaMode + // enum `0x811aa8`. Only ADD changes draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as + // straight alpha, a stated gap — see [`BlendMode`]); an unrecognised name leaves the mode + // alone, which is what the enum-table lookup does with a string it cannot match. + // + // The mode is STORED as the mode. It used to collapse to `additive = (mode == "ADD")` on the + // way in, which is everything the renderer needs and strictly less than `GetBlendMode` has to + // answer — a texture set to `"MOD"` would have read back as `"BLEND"`. m.set( "SetBlendMode", lua.create_function(|lua, (this, mode): (Table, String)| { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().additive = mode.eq_ignore_ascii_case("ADD"); + if let Some(blend) = BlendMode::parse(&mode) { + model.region_data.entry(rh).or_default().blend = blend; + } Ok(()) })?, )?; + // GetBlendMode() — ONE string, the enum's own spelling (`0x79a890`, table `0x87c128`, argc 1, + // arity 1, kinds `(string?)`). A Texture nothing has called the setter on answers `"BLEND"`, + // the CSimpleTexture ctor's `[+0xd0] = 2` (`0x76fc64`). + // + // **This one was answering nil, and nil is not an error here — it is a wrong picture.** + // `ShaguTweaks/mods/dark-ui-elements.lua:169` reads + // `elseif region.GetBlendMode and region:GetBlendMode() == "ADD" then` while recolouring a + // Blizzard frame's children: the `and` guard means a missing method never raised, it just took + // the other branch, so every additive texture in the frame got the dark recolour the reference + // leaves alone. The kinds column's `string?` is the reference's own nil leg (the name pointer + // can be NULL for a mode outside the table); the five modes are all this engine can hold, so + // nothing here reaches it. + m.set( + "GetBlendMode", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model + .region_data + .get(&rh) + .map_or(BlendMode::default(), |d| d.blend) + .name()) + })?, + )?; + + // SetTexCoordModifiesRect(flag) / GetTexCoordModifiesRect() — `0x79c080` / `0x79c120`, table + // `0x87c128`. The setter writes the reference's `[texture+0x124]` and is its only writer; the + // getter answers `1`/`nil` (kinds `(nil) | (number)`, the predicate law — decision 2118 — not a + // Lua boolean). + // + // **The flag's GEOMETRY effect is not wired, and that is stated rather than implied.** In the + // reference the flag gates a rect-recompute leg (`ui.md:4619`, `0x770462`): with it set, a + // `SetTexCoord` re-derives the region's own rect from the UV quad instead of leaving the rect + // where the anchors put it and resampling inside it. Wiring that means the region resolve in + // `region::layout` reading this flag and taking the rect from `RegionData::tex_coords` — a + // resolve-order change. Nothing in the stock UI or in either addon corpus calls the SETTER, so + // there is no measured case to build it against; what has a caller is the GETTER — + // `pfUI/modules/thirdparty-tbc.lua:319` does a bare `if icon:GetTexCoordModifiesRect() then` on + // a Texture to choose between two `SetTexCoord` rectangles, and against this VM that raised. + // So: the flag is stored, answered truthfully, and read by nothing. See + // [`RegionData::tex_coord_modifies_rect`]. + m.set( + "SetTexCoordModifiesRect", + lua.create_function(|lua, (this, arg): (Table, Value)| { + let rh = region_handle_of(lua, &this)?; + // The reference's shared flag-argument truth table (`0x6f1c10(L, 2, default)`), whose + // DEFAULT byte for this binding is unread — no corpus site calls the setter at all, so + // the no-argument leg is doubly unreachable and `false` is the conservative pick rather + // than a claim about `0x79c080`. + let on = crate::script::binding_abi::bool_or_default(Some(&arg), false); + let mut model = lua.app_data_mut::().expect("model"); + model + .region_data + .entry(rh) + .or_default() + .tex_coord_modifies_rect = on; + Ok(()) + })?, + )?; + m.set( + "GetTexCoordModifiesRect", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(crate::script::binding_abi::flag( + model + .region_data + .get(&rh) + .is_some_and(|d| d.tex_coord_modifies_rect), + )) + })?, + )?; m.set( "SetDrawLayer", @@ -464,22 +544,23 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - // SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the - // later-era Texture API, shipped early: the world-map player arrow's stand-in rotation — - // see `QuadContent::Texture::rotation`). No-arg/nil resets to 0. - m.set( - "SetRotation", - lua.create_function(|lua, (this, radians): (Table, Option)| { - let rh = region_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .region_data - .entry(rh) - .or_default() - .rotation = radians.unwrap_or(0.0); - Ok(()) - })?, - )?; + // `SetRotation` WAS here, on the Texture leaf, and is GONE. 1.12 registers the name once, in + // the **PlayerModel** table `0x84f1fc` (`0x505f00`, argc 2 — the paper doll's rotate arrows), + // which we already answer through `modelframe`; it is in neither region map, and the carve + // above lists it among the five names that are in NEITHER (`texture-fontstring-method-split.md`). + // Ours was a later-era Texture verb shipped early for the world-map player arrow's stand-in + // rotation — and that arrow has since become a real Model frame driven by `ModelState::facing` + // (`script::worldmap_arrow`), so the verb's own reason went with it. + // + // Removal is safe by census, not by assumption: every `SetRotation` in this repo, in + // `assets/ui`, in the stock FrameXML/GlueXML and in both addon corpora has a MODEL receiver — + // `pfUI/api/ui-widgets.lua:580`'s `EnableClickRotate` hooks a modelframe's OnUpdate, and + // `CustomNameplates/options.lua:308` is `optionsFrame.preview.model:SetRotation(0.61)`. Not one + // texture receiver anywhere. + // + // `RegionData::rotation` and `QuadContent::Texture::rotation` now have no writer left. They are + // deliberately NOT pruned in the same change: that plumbing runs into the app's quad emit and + // its removal is a wider prune with its own review, not a tail on a method-surface fix. // SetTexCoord(left, right, top, bottom) — the 4-edge form (XML ``): a UV sub-rect in // 0..1 texture space (top-left origin) the Texture region samples, slicing quadrant/atlas art diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs index 291f58bd..97f3ffa5 100644 --- a/crates/benilla-ui/src/script/region/text.rs +++ b/crates/benilla-ui/src/script/region/text.rs @@ -9,8 +9,8 @@ //! EditBox's table gets them. The justify law likewise lives once in [`crate::justify`]. //! //! What legitimately stays here is the surface a FontString alone has: the string itself -//! (`SetText`/`SetFormattedText`/`GetText`), the measured extents, `Set/GetJustifyH`/`V`, -//! `SetNonSpaceWrap`/`CanNonSpaceWrap`, and `SetTextHeight`. +//! (`SetText`/`GetText`), the measured extents, `Set/GetJustifyH`/`V`, `SetNonSpaceWrap`/ +//! `CanNonSpaceWrap`, and `SetTextHeight`. //! //! The per-property override a region has over its inherited font object is unaffected — that is //! the severance mask (`FontExplicit`), which the shared block writes the same way this file did. @@ -44,26 +44,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - // SetFormattedText(fmt, ...) = SetText(format(fmt, ...)) — routed through the stdlib's - // positional-aware `format` so `%N$s` specs behave (a consensus call across the 0068 targets). - m.set( - "SetFormattedText", - lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| { - let format: mlua::Function = lua - .globals() - .get::("string")? - .get::("format")?; - // The formatted result is bytes too — `%s` of a sliced string is a sliced string. - let text = format.call::(args)?.to_string_lossy(); - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let data = model.region_data.entry(rh).or_default(); - data.text = Some(text); - data.alpha_gradient = None; - model.touch_measure(rh); - Ok(()) - })?, - )?; + // **No `SetFormattedText`.** A later-expansion name: absent from the client's 32-entry + // FontString map, from the stock 1.12 chain, and from both addon corpora (2142's census). The + // era spelling is `SetText(format(fmt, ...))`, and `format` here is already the + // positional-aware one ([`crate::strings`]), so `%N$s` behaves at the call site the same way + // it behaved inside this shim. // GetText — **an EMPTY string comes back as `nil`, and that substitution is the getter's own** // (`FontString:GetText 0x79d690`, wow-re @@ -138,7 +123,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // GetStringWidth is the **natural, unwrapped** extent — never the declared box, and never the // wrapped one (wow-re `fontstring-overflow.md`, "The measurement echo": the reference's getter // re-measures the raw text with NO wrap constraint). Unlike `GetWidth` below it deliberately - // does NOT fall back to an explicit `SetSize`: the declared width is the very thing a caller + // does NOT fall back to a declared `SetWidth`: that width is the very thing a caller // asks this to be independent of. A kit that sizes a box from this number and then sets a width // on the string — which is what the reference's own `PanelTemplates_TabResize` does — would // otherwise read its own output back as its next input and never settle (decision 0997, the diff --git a/crates/benilla-ui/src/script/region_map.rs b/crates/benilla-ui/src/script/region_map.rs index 58ea2bcc..7038af59 100644 --- a/crates/benilla-ui/src/script/region_map.rs +++ b/crates/benilla-ui/src/script/region_map.rs @@ -53,8 +53,9 @@ //! **Exactly the 19, and no more.** `Show`/`Hide`/`IsShown`/`IsVisible`/`SetAlpha`/`GetAlpha` look //! like they belong and do not: Frame and Texture each register their *own*, at different //! addresses (`texture-fontstring-method-split.md` §3), so `WorldFrame.Show(someTexture)` fails on -//! the real client and must keep failing here. `SetSize` is in neither table — it is 1.12-absent -//! and ours, so it stays where it is. The map is the unit. +//! the real client and must keep failing here. (`SetSize` used to sit outside the 19 for the +//! opposite reason — in neither table because 1.12 has no such verb at all; decision 2142 removed +//! it rather than filing it.) The map is the unit. use mlua::{Function, Lua, MultiValue, Table, Value}; diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 7ac9e597..dc24cd7e 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -1,18 +1,28 @@ //! The `ScrollFrame` method surface (`CSimpleScrollFrame`) — the ScrollFrame mechanism (decision -//! 0112, the engine's last structural gap): `SetScrollChild`/`GetScrollChild`, the vertical scroll -//! offset (`SetVerticalScroll`/`GetVerticalScroll`), and the live range -//! (`GetVerticalScrollRange`/`UpdateScrollChildRect`). The offset setter and the range are -//! byte-pinned (decisions 1338, 2017): `0x786db0` stores the offset VERBATIM — the engine never -//! clamps it — and `0x786e30` measures the range off the scroll child's subtree. The rest is -//! spec-faithful to the documented widget contract (the Era `ScrollFrameTemplate` Lua drives its -//! scrollbar off exactly these), same posture as StatusBar's fill. Horizontal scroll -//! (`SetHorizontalScroll`/…) is out of scope: no 1.12 template drives it. +//! 0112, the engine's last structural gap): `SetScrollChild`/`GetScrollChild`, the two scroll +//! offsets (`Set`/`GetVerticalScroll`, `Set`/`GetHorizontalScroll`), and the live ranges +//! (`Get{Vertical,Horizontal}ScrollRange`/`UpdateScrollChildRect`). The offset setters and the +//! ranges are byte-pinned (decisions 1338, 2017): `0x786db0` stores the offset VERBATIM — the +//! engine never clamps it — and `0x786e30` measures both ranges off the scroll child's subtree. The +//! rest is spec-faithful to the documented widget contract (the Era `ScrollFrameTemplate` Lua +//! drives its scrollbar off exactly these), same posture as StatusBar's fill. //! -//! The actual geometry — the scroll child's anchors pinned to the frame + the scroll offset, and +//! **The two axes are one mechanism, not a mechanism and a special case.** The reference's nine +//! ScrollFrame bindings are four symmetric pairs plus `UpdateScrollChildRect`, and the C++ under +//! them is literally cloned: `0x786d30` (horizontal) is the byte-clone of `0x786db0` (vertical) +//! against `[+0x324]`/`[+0x32c]` instead of `[+0x328]`/`[+0x334]`, and one `0x786e30` computes +//! both ranges out of one subtree union (wow-re `scrollframe-offset-and-range-law.md`). This module +//! is written that way too — one helper per concept, taken on the axis asked for. That the +//! horizontal half went unwritten until the corpus asked for it (`aux-addon` reads and writes both +//! axes in one function) is why this doc used to say horizontal scroll "is out of scope: no 1.12 +//! template drives it" — true of the stock templates, and never true of the addons. +//! +//! The actual geometry — the scroll child's anchors pinned to the frame + the scroll offsets, and //! the clip every descendant of the child draws/hits within — lives in [`super::mod@super`]'s //! `resolve`/`extract`/`hit_test` (the layout-graph override + the ancestor-walk clip helper); this -//! module is the thin Lua binding over the per-frame [`ScrollFrameState`], plus the two script -//! hooks (`OnVerticalScroll`/`OnScrollRangeChanged`) a scrollbar's `OnValueChanged`/`OnLoad` wires to. +//! module is the thin Lua binding over the per-frame [`ScrollFrameState`], plus the three script +//! hooks (`OnVerticalScroll`/`OnHorizontalScroll`/`OnScrollRangeChanged`) a scrollbar's +//! `OnValueChanged`/`OnLoad` wires to. //! //! The methods live in their own registry table, consulted by the frame `__index` dispatcher only //! for ScrollFrame frames — so duck-typing addons (`if frame.SetScrollChild then …`) see `nil` on @@ -47,9 +57,23 @@ fn with_scroll( } } -/// `GetVerticalScrollRange()`'s live computation: `max(0, contentExtent − frameHeight)` from the -/// **resolved** rects — `0.0` when the frame is unresolved or there is no child. Never cached: the -/// scroll offset always clamps against the current layout, not a stale snapshot. +/// Which axis a scroll offset, span or range is taken on. +/// +/// One type rather than two code paths, because the reference computes both ranges in ONE pass +/// over ONE subtree union (`0x786e30`: `[+0x31c] = max(0, maxRIGHT − minLEFT − width) / scale`, +/// `[+0x320] = max(0, maxTOP − minBOTTOM − height) / scale`) — the axes differ only in which pair +/// of edges the union tracks and which frame dimension is subtracted. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Axis { + /// `maxRIGHT − minLEFT` against the frame's width — `GetHorizontalScrollRange`, `[+0x31c]`. + Horizontal, + /// `maxTOP − minBOTTOM` against the frame's height — `GetVerticalScrollRange`, `[+0x320]`. + Vertical, +} + +/// `Get{Vertical,Horizontal}ScrollRange()`'s live computation: `max(0, contentExtent − frameExtent)` +/// from the **resolved** rects — `0.0` when the frame is unresolved or there is no child. Never +/// cached: the scroll offset always clamps against the current layout, not a stale snapshot. /// /// **The content extent is the scroll child's whole SUBTREE, not the child frame's own height** /// (wow-re `system/ui/scratch/simplehtml-markup-engine.md` §4.5, byte-identified during the @@ -66,7 +90,7 @@ fn with_scroll( /// directly — the guild-info EditBox, the options panes, every faux list — is unchanged, because /// there its own rect already IS its subtree. /// -/// In **LOCAL units**, each height divided by the owning frame's effective scale — i.e. exactly +/// In **LOCAL units**, each extent divided by the owning frame's effective scale — i.e. exactly /// `child:GetHeight() - self:GetHeight()`, the widget contract this method states. The resolved /// rects are screen px, and the scroll offset is NOT: `SetVerticalScroll` becomes the scroll /// child's anchor y-offset, which the solver multiplies by that child's scale (`resolve`'s @@ -75,7 +99,7 @@ fn with_scroll( /// stops short of the end and the tail of the content is unreachable. Invisible at scale 1.0, /// which is every 1.12-native window; the era-scaled options window (ERA_WINDOW_SCALE 0.78) is the /// first real-scroll customer that isn't, and it lost 22% of its travel. -fn scroll_range(model: &Model, h: FrameHandle) -> f32 { +fn scroll_range(model: &Model, h: FrameHandle, axis: Axis) -> f32 { let child = match model.arena.frame(h).map(|f| &f.kind_state) { Some(KindState::Scroll(s)) => s.child, _ => None, @@ -89,28 +113,39 @@ fn scroll_range(model: &Model, h: FrameHandle) -> f32 { .filter(|s| s.abs() >= 1e-6) .unwrap_or(1.0) }; - let Some(frame_h) = model.resolved.get(&h).map(|r| r.height() / scale_of(h)) else { + let Some(viewport) = model.resolved.get(&h).map(|r| { + let extent = match axis { + Axis::Horizontal => r.width(), + Axis::Vertical => r.height(), + }; + extent / scale_of(h) + }) else { return 0.0; }; - let Some((top, bottom)) = subtree_span(model, child) else { + let Some((far, near)) = subtree_span(model, child, axis) else { return 0.0; }; - ((top - bottom) / scale_of(child) - frame_h).max(0.0) + ((far - near) / scale_of(child) - viewport).max(0.0) } -/// The vertical span `(top, bottom)` of `f`'s subtree in SCREEN px — its own resolved rect unioned -/// with every VISIBLE descendant region and child frame, recursively (`0x786f80`). `None` when -/// nothing in the subtree resolved. +/// The span of `f`'s subtree along `axis`, in SCREEN px, as `(far, near)` — `(top, bottom)` +/// vertically, `(right, left)` horizontally — its own resolved rect unioned with every VISIBLE +/// descendant region and child frame, recursively (`0x786f80`). `None` when nothing in the subtree +/// resolved. /// /// Visibility is the client's own guard on both lists: a hidden block contributes no scroll range, /// which is what stops a window's parked art from inventing travel nobody can use. -fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> { +fn subtree_span(model: &Model, f: FrameHandle, axis: Axis) -> Option<(f32, f32)> { + let edges = |r: &crate::layout::Rect| match axis { + Axis::Horizontal => (r.right, r.left), + Axis::Vertical => (r.top, r.bottom), + }; let frame = model.arena.frame(f)?; - let mut span: Option<(f32, f32)> = model.resolved.get(&f).map(|r| (r.top, r.bottom)); - let union = |s: &mut Option<(f32, f32)>, t: f32, b: f32| { + let mut span: Option<(f32, f32)> = model.resolved.get(&f).map(edges); + let union = |s: &mut Option<(f32, f32)>, far: f32, near: f32| { *s = Some(match *s { - Some((ot, ob)) => (ot.max(t), ob.min(b)), - None => (t, b), + Some((of, on)) => (of.max(far), on.min(near)), + None => (far, near), }); }; for &rh in &frame.regions { @@ -118,15 +153,16 @@ fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> { continue; } if let Some(r) = model.region_resolved.get(&rh) { - union(&mut span, r.top, r.bottom); + let (far, near) = edges(r); + union(&mut span, far, near); } } for &ch in &frame.children { if !model.arena.frame(ch).is_some_and(|c| c.effective_visible) { continue; } - if let Some((t, b)) = subtree_span(model, ch) { - union(&mut span, t, b); + if let Some((far, near)) = subtree_span(model, ch, axis) { + union(&mut span, far, near); } } span @@ -242,7 +278,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { return Ok(()); } lua.app_data_mut::().expect("model").touch_layout(); - fire_vertical_scroll(lua, &this, px) + fire_scroll(lua, &this, "OnVerticalScroll", px) })?, )?; m.set( @@ -254,7 +290,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model app_data"); - Ok(scroll_range(&model, h)) + Ok(scroll_range(&model, h, Axis::Vertical)) + })?, + )?; + + // ── The horizontal axis: the same three verbs on `[+0x324]`/`[+0x31c]` ───────────────────── + // + // `SetHorizontalScroll(px)` `0x7912f0` → `0x786d30`, which is the BYTE-CLONE of + // `SetVerticalScroll`'s `0x786db0` against the horizontal fields (wow-re + // `scrollframe-offset-and-range-law.md` §2.2; `item9-firing34-glue786.md` identified the pair). + // So every law stated above holds verbatim on this axis: no clamp, the same 2⁻²² change-gate + // against the OLD offset alone, the same re-anchor, and `OnHorizontalScroll(self, offset)` + // fired only on a real change. Nothing here is a horizontal special case. + // + // The one thing that IS specific to this axis is the SIGN, and it is the reference's: + // `0x787100` passes both offsets to `SetPoint(TOPLEFT, self, TOPLEFT, +hScroll, +vScroll)` + // unnegated, so positive x pushes the child right (revealing nothing) where positive y lifts it + // (revealing content below). `aux-addon/tabs/search/filter.lua:315-322` — the corpus caller + // that made this pair worth writing, reading both axes in one line and writing both in the next + // — bounds x into `[min(0, frameWidth - contentWidth - 10), 0]` and y into + // `[0, contentHeight - frameHeight]`, which is the same asymmetry read off the other side of + // the API. See [`ScrollFrameState::horizontal`]. + m.set( + "SetHorizontalScroll", + lua.create_function(|lua, (this, px): (Table, f32)| { + let changed = with_scroll(lua, &this, |s| { + let changed = s.horizontal.to_bits() != px.to_bits(); + s.horizontal = px; + changed + })?; + if !changed { + return Ok(()); + } + lua.app_data_mut::().expect("model").touch_layout(); + fire_scroll(lua, &this, "OnHorizontalScroll", px) + })?, + )?; + m.set( + "GetHorizontalScroll", + lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.horizontal))?, + )?; + m.set( + "GetHorizontalScrollRange", + lua.create_function(|lua, this: Table| { + let h = frame_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model app_data"); + Ok(scroll_range(&model, h, Axis::Horizontal)) })?, )?; // UpdateScrollChildRect() — the Era template calls this after resizing its content to re-drive @@ -292,21 +373,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { for rh in strings { super::measure::ensure_measured(lua, rh); } - let (id, range) = { + let (id, x_range, y_range) = { let mut model = lua.app_data_mut::().expect("model app_data"); super::UiScript::resolve_layout(&mut model); - let range = scroll_range(&model, h); - (model.frame_id(h), range) + let x_range = scroll_range(&model, h, Axis::Horizontal); + let y_range = scroll_range(&model, h, Axis::Vertical); + (model.frame_id(h), x_range, y_range) }; // That forced resolve can have MOVED sizes — this method exists precisely because the // caller just resized its content — so drain the queue before the range notify: the // reference fires `OnSizeChanged` from `ApplyRect`, i.e. during the layout, not after. event::fire_size_changes(lua); + // `OnScrollRangeChanged(self, xRange, yRange)` — **horizontal first**, and that order is + // byte-derived rather than assumed: at `0x786eff` the vertical result is on top of the + // x87 stack, is `fstp`'d first and therefore pushed DEEPEST, so it lands as `arg2` + // (wow-re `scrollframe-offset-and-range-law.md` §3.6, where two of the round's own + // workers published it the other way round). It is why stock + // `UIPanelScrollFrameTemplate` hands `arg2` to `ScrollFrame_OnScrollRangeChanged`. + // `arg1` was a hardcoded `0.0` here for as long as there was no horizontal range to + // put in it. if let Err(e) = event::fire_widget_handler( lua, id, "OnScrollRangeChanged", - vec![Value::Number(0.0), Value::Number(f64::from(range))], + vec![ + Value::Number(f64::from(x_range)), + Value::Number(f64::from(y_range)), + ], ) { lua.app_data_mut::() .expect("model app_data") @@ -321,20 +414,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) } -/// Fire `OnVerticalScroll(self, offset)` (the ScrollFrame's own script slot). Fired outside any -/// model borrow; errors go to [`Model::errors`] like every other widget handler. -fn fire_vertical_scroll(lua: &Lua, this: &Table, value: f32) -> mlua::Result<()> { +/// Fire `On{Vertical,Horizontal}Scroll(self, offset)` (the ScrollFrame's own script slots — the +/// reference's `[+0x334]`/`[+0x32c]`, fired from the setter clones `0x786db0`/`0x786d30`). Fired +/// outside any model borrow; errors go to [`Model::errors`] like every other widget handler. +fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Result<()> { let id = { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); model.frame_id(h) }; - if let Err(e) = event::fire_widget_handler( - lua, - id, - "OnVerticalScroll", - vec![Value::Number(f64::from(value))], - ) { + if let Err(e) = + event::fire_widget_handler(lua, id, script, vec![Value::Number(f64::from(value))]) + { lua.app_data_mut::() .expect("model app_data") .errors diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index 99fcbb59..a944d0a6 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -171,21 +171,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // Enable/Disable/IsEnabled — a disabled slider ignores thumb drag (gated in the pointer path). - m.set( - "Enable", - lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.enabled = true))?, - )?; - m.set( - "Disable", - lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.enabled = false))?, - )?; - m.set( - "IsEnabled", - // 1.12 returns 1/nil; the house API target is the Era boolean (decision 0068) — every - // transcribed `if slider:IsEnabled()` reads both identically. - lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.enabled))?, - )?; + // `Enable`/`Disable`/`IsEnabled` WERE here and are GONE, with the `enabled` state they drove. + // **A 1.12 Slider has no enabled state at all**: the three names are registered once each, in + // the BUTTON table `0x879d00` (`0x77fef0`/`0x77ffd0`/`0x7800b0`), and the Slider's own LoadXML + // `0x789580` takes `drawLayer`/`minValue`/`maxValue`/`valueStep`/`defaultValue`/`orientation` + // and nothing else (wow-re `rf28-typed-widget-loadxml.md` §Slider) — so there was no way in + // from Lua *or* from XML, and the flag could only ever read `true`. + // + // They were a superset in PRESENCE, which is what 1189 records the cost of: a duck-typing addon + // that branches on `if widget.IsEnabled then` reads a Slider as a Button. Ours also answered a + // Lua BOOLEAN, which the reference has no query that does (decision 2118) — `IsEnabled 0x7800b0` + // is number-1/number-0 and never even nil. + // + // Removed rather than corrected because nothing calls them on a Slider receiver: a + // receiver-typed grep over this repo, `assets/ui`, the stock FrameXML/GlueXML and both addon + // corpora (110 top-20 + 219 vanilla) finds zero `slider*:Enable/Disable/IsEnabled` sites. + // `begin_drag` below lost its `enabled` gate with them — behaviour-neutral, since no caller + // could clear the flag. // SetThumbTexture(path [, drawLayer]) | SetThumbTexture(r, g, b [, a]) — the same two forms as a // region's SetTexture, targeting the thumb region (created on first use). Mirrors @@ -339,10 +341,15 @@ pub(crate) struct SliderDrag { pub(crate) grab_offset: f32, } -/// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is an **enabled** -/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). Where the -/// press grabs is [`slider_grab`]'s to say — one law, shared with the glue lane's scrollbars, so -/// an in-game bar and a character-screen bar cannot drift apart on feel. +/// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is a Slider with a +/// resolved rect, begin a drag capture (records [`Model::slider_drag`]). Where the press grabs is +/// [`slider_grab`]'s to say — one law, shared with the glue lane's scrollbars, so an in-game bar +/// and a character-screen bar cannot drift apart on feel. +/// +/// There is no enabled gate: 1.12 gives a Slider no enabled state to gate on (see `install`'s note +/// where `Enable`/`Disable`/`IsEnabled` used to be). A slider that should not take the press is +/// kept off it the way the reference keeps anything off it — `EnableMouse(false)`, upstream of here +/// in the hit test. /// /// Returns `Some((frame id, new value))` when the press itself changed the value (the track jump) /// — the caller fires `OnValueChanged` outside the model borrow, exactly like [`drag_move`]'s @@ -355,13 +362,10 @@ pub(super) fn begin_drag( ) -> Option<(u32, f32)> { let h = hit?; let r = model.resolved.get(&h).copied()?; - let (enabled, vertical, fraction, thumb) = match model.arena.frame(h).map(|f| &f.kind_state) { - Some(KindState::Slider(s)) => (s.enabled, s.vertical, s.fraction(), s.thumb), + let (vertical, fraction, thumb) = match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::Slider(s)) => (s.vertical, s.fraction(), s.thumb), _ => return None, }; - if !enabled { - return None; - } // No thumb region → `0x789ba0`'s `+0x328` gate: the press still captures (the dispatcher's // `mov [ebx+0x80],esi` is unconditional), the value never moves. let Some(size) = thumb_extent(model, thumb) else { diff --git a/crates/benilla-ui/src/script/surface.rs b/crates/benilla-ui/src/script/surface.rs new file mode 100644 index 00000000..746dfeb8 --- /dev/null +++ b/crates/benilla-ui/src/script/surface.rs @@ -0,0 +1,191 @@ +//! **benilla's widget METHOD surface, asked of a running VM** — the shared measurement behind +//! the `dump_widget_methods` example and the widget-surface gate +//! (`script::tests::widget_surface`, decision 2142). +//! +//! It exists for the same reason `dump_globals` does (decisions 1188, 1189): **it is a run, not a +//! grep**. `_G` is not the whole surface an addon can tell apart — most of what an addon touches +//! is reached through a *widget*, and an addon's test for "does this client have X" is +//! `if frame.SetBackdrop then`, i.e. what the `__index` chain answers on a live instance. A regex +//! over `m.set("Name", …)` cannot answer that: it cannot see which registry table a kind actually +//! chains to, it cannot see the Texture/FontString leaf split ([`super::region`]), and it reads a +//! name registered under a `format!` family as one method. +//! +//! So every row is one live object being asked `type(obj.Name) == "function"`, exactly as an addon +//! asks it. The only thing **not** asked of the VM is the *candidate name list* — a metatable whose +//! `__index` is a Rust dispatcher is not enumerable from Lua, so the candidates are the union of +//! the keys of the VM's own method tables ([`METHOD_TABLES`]), read out of the Lua registry at +//! runtime rather than out of the source. A key that no longer resolves to a table is a hard error +//! rather than a silent loss of candidates: a quietly-missing candidate source would under-report +//! the surface, which is the exact failure this measurement exists to prevent. +use std::collections::BTreeSet; + +use mlua::{Table, Value}; + +use super::UiScript; + +/// The named-registry method tables the VM installs — the **candidate name source**, read from the +/// live registry. +/// +/// This is not the class chain (the chain is `object::kind_method_registries`' business and this +/// instrument deliberately does not model it — it asks the instance instead). It is only the +/// universe of names worth probing: every name benilla registers on any widget appears as a key in +/// one of these, so the union is a superset of our surface and the per-instance probe decides +/// membership. +const METHOD_TABLES: &[&str] = &[ + "__benilla_frame_methods", + "__benilla_region_methods", + "__benilla_texture_methods", + "__benilla_fontstring_methods", + "__benilla_title_methods", + "__benilla_font_methods", + "__benilla_button_methods", + "__benilla_checkbutton_methods", + "__benilla_lootbutton_methods", + "__benilla_editbox_methods", + "__benilla_statusbar_methods", + "__benilla_slider_methods", + "__benilla_scrollframe_methods", + "__benilla_simplehtml_methods", + "__benilla_colorselect_methods", + "__benilla_model_methods", + "__benilla_playermodel_methods", + "__benilla_dressupmodel_methods", + "__benilla_tabardmodel_methods", + "__benilla_plain_messageframe_methods", + "__benilla_scrollingmessageframe_methods", + "__benilla_minimap_methods", + "__benilla_tooltip_methods", +]; + +/// Every widget/region/font class benilla can hand an addon, and one Lua expression that makes one. +/// +/// **Ordered, and the order is load-bearing**: the region and font rows are made *by* the plain +/// frame the first row creates, so `Frame` comes first. Each instance is published as the global +/// `DW_`; the frame kinds also publish their own `CreateFrame` name, which is harmless. +/// +/// `TaxiRouteFrame` is absent on purpose — it is a registered `CreateFrame` type that *is* a +/// `Frame` and nothing else (decision 1828), so it would be the `Frame` row twice. +const CLASSES: &[(&str, &str)] = &[ + ("Frame", r#"CreateFrame("Frame", "DWFrameN", UIParent)"#), + ( + "WorldFrame", + r#"CreateFrame("WorldFrame", "DWWorldN", UIParent)"#, + ), + ("Button", r#"CreateFrame("Button", "DWButtonN", UIParent)"#), + ( + "LootButton", + r#"CreateFrame("LootButton", "DWLootN", UIParent)"#, + ), + ( + "CheckButton", + r#"CreateFrame("CheckButton", "DWCheckN", UIParent)"#, + ), + ("EditBox", r#"CreateFrame("EditBox", "DWEditN", UIParent)"#), + ( + "StatusBar", + r#"CreateFrame("StatusBar", "DWStatusN", UIParent)"#, + ), + ("Slider", r#"CreateFrame("Slider", "DWSliderN", UIParent)"#), + ( + "ScrollFrame", + r#"CreateFrame("ScrollFrame", "DWScrollN", UIParent)"#, + ), + ("Model", r#"CreateFrame("Model", "DWModelN", UIParent)"#), + ( + "PlayerModel", + r#"CreateFrame("PlayerModel", "DWPlayerModelN", UIParent)"#, + ), + ( + "DressUpModel", + r#"CreateFrame("DressUpModel", "DWDressN", UIParent)"#, + ), + ( + "TabardModel", + r#"CreateFrame("TabardModel", "DWTabardN", UIParent)"#, + ), + ( + "MessageFrame", + r#"CreateFrame("MessageFrame", "DWMessageN", UIParent)"#, + ), + ( + "ScrollingMessageFrame", + r#"CreateFrame("ScrollingMessageFrame", "DWScrollMsgN", UIParent)"#, + ), + ( + "ColorSelect", + r#"CreateFrame("ColorSelect", "DWColorN", UIParent)"#, + ), + ( + "SimpleHTML", + r#"CreateFrame("SimpleHTML", "DWHtmlN", UIParent)"#, + ), + ( + "MovieFrame", + r#"CreateFrame("MovieFrame", "DWMovieN", UIParent)"#, + ), + ( + "GameTooltip", + r#"CreateFrame("GameTooltip", "DWTooltipN", UIParent)"#, + ), + ( + "Minimap", + r#"CreateFrame("Minimap", "DWMinimapN", UIParent)"#, + ), + ("Texture", r#"DW_Frame:CreateTexture("DWTexN")"#), + ("FontString", r#"DW_Frame:CreateFontString("DWFSN")"#), + ("TitleRegion", r#"DW_Frame:CreateTitleRegion()"#), + ("Font", r#"CreateFont("DWFontN")"#), +]; + +/// Every `(class, method)` pair this VM answers — one live instance per class, probed with the +/// membership test an addon writes. +/// +/// **A class that cannot be instantiated is REPORTED, never skipped**: its row is +/// `(class, "!NOT-INSTANTIABLE")`. A census that silently drops a class reads as "that class has +/// no methods", which is the wrong answer to a different question. +pub fn widget_method_census(script: &UiScript) -> mlua::Result> { + let mut candidates: BTreeSet = BTreeSet::new(); + for key in METHOD_TABLES { + let table: Table = script.lua().named_registry_value(key).map_err(|e| { + mlua::Error::runtime(format!( + "candidate source '{key}' is not a table in this VM ({e}) — a renamed or retired \ + method table would silently shrink this census; fix the list, do not ignore it" + )) + })?; + for pair in table.pairs::() { + let (k, _) = pair?; + if let Value::String(s) = k { + candidates.insert(s.to_str()?.to_string()); + } + } + } + // Hand the names to the VM once; the per-class probe is then one chunk, not one per name. + let names = script.lua().create_table()?; + for (i, n) in candidates.iter().enumerate() { + names.set(i + 1, n.as_str())?; + } + script.lua().globals().set("DW_NAMES", names)?; + + let mut rows: Vec<(String, String)> = Vec::new(); + for (class, expr) in CLASSES { + if let Err(e) = script.run(&format!("DW_{class} = {expr}")) { + eprintln!("{class}: could not be instantiated: {e}"); + rows.push(((*class).to_string(), "!NOT-INSTANTIABLE".to_string())); + continue; + } + let found: Vec = script.eval(&format!( + "local o = DW_{class} \ + local out = {{}} \ + for i = 1, table.getn(DW_NAMES) do \ + local n = DW_NAMES[i] \ + if type(o[n]) == 'function' then table.insert(out, n) end \ + end \ + return out" + ))?; + for name in found { + rows.push(((*class).to_string(), name)); + } + } + rows.sort(); + Ok(rows) +} diff --git a/crates/benilla-ui/src/script/taxi.rs b/crates/benilla-ui/src/script/taxi.rs index 054f9eee..d1864020 100644 --- a/crates/benilla-ui/src/script/taxi.rs +++ b/crates/benilla-ui/src/script/taxi.rs @@ -242,7 +242,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // `UnitOnTaxi(unit)` (`0x517a40`) → **the number 1 or nil**, never a Lua boolean — the unit // predicate family's one return shape (decisions 1830, 2043; the push law is - // `crate::script::binding_abi::predicate`). It lives here rather than beside its 22 siblings + // `crate::script::binding_abi::flag`). It lives here rather than beside its 22 siblings // because the ride flag it reads is this module's, not `UnitState`'s — which is exactly how it // came to be the one predicate still answering a `bool` after the family was fixed. // @@ -260,7 +260,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let unit = crate::script::binding_abi::string_arg(lua, unit, r#"Usage: UnitOnTaxi("unit")"#)?; let model = lua.app_data_ref::().expect("model"); - Ok(crate::script::binding_abi::predicate( + Ok(crate::script::binding_abi::flag( unit.eq_ignore_ascii_case("player") && model.taxi_riding, )) })?, diff --git a/crates/benilla-ui/src/script/tests/anchors.rs b/crates/benilla-ui/src/script/tests/anchors.rs index 22abe68a..512d78b7 100644 --- a/crates/benilla-ui/src/script/tests/anchors.rs +++ b/crates/benilla-ui/src/script/tests/anchors.rs @@ -15,7 +15,7 @@ fn setpoint_explicit_nil_relative_to_keeps_offsets() { r#" local f = CreateFrame("Frame", "Nil") f:SetPoint("TOPLEFT", nil, "TOPLEFT", 40, -40) - f:SetSize(300, 200) + f:SetWidth(300); f:SetHeight(200) "#, ) .unwrap(); @@ -40,7 +40,7 @@ fn setpoint_resolve_size_and_rect() { r#" local f = CreateFrame("Frame", "Sized") f:SetPoint("TOPLEFT", 10, -5) -- relativeTo = screen (default), relativePoint = TOPLEFT - f:SetSize(200, 50) + f:SetWidth(200); f:SetHeight(50) "#, ) .unwrap(); @@ -68,7 +68,7 @@ fn setpoint_resolve_size_and_rect() { #[test] fn getwidth_falls_back_to_explicit_size_before_resolve() { let s = script(); - // No SetPoint ⇒ unresolvable; GetWidth returns the explicit SetSize value. + // No SetPoint ⇒ unresolvable; GetWidth returns the explicit SetWidth value. let w: f32 = s .eval(r#"local f = CreateFrame("Frame"); f:SetWidth(123); return f:GetWidth()"#) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/backdrop.rs b/crates/benilla-ui/src/script/tests/backdrop.rs index 4b9d006d..b9045483 100644 --- a/crates/benilla-ui/src/script/tests/backdrop.rs +++ b/crates/benilla-ui/src/script/tests/backdrop.rs @@ -13,7 +13,7 @@ fn backdrop_installs_and_extracts_pieces_with_colors() { r#" local f = CreateFrame("Frame", "Plate") f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100) - f:SetSize(200, 100) + f:SetWidth(200); f:SetHeight(100) f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true, tileSize = 16, edgeSize = 16, @@ -129,7 +129,7 @@ fn get_backdrop_round_trips_through_set_backdrop() { r#" local f = CreateFrame("Frame", "BC2Plate") f:SetPoint("CENTER") - f:SetSize(54, 54) + f:SetWidth(54); f:SetHeight(54) f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true, tileSize = 32, edgeSize = 32, insets = { left = 11, right = 12, top = 12, bottom = 11 } }) @@ -166,7 +166,7 @@ fn set_backdrop_nil_tears_down() { r#" local f = CreateFrame("Frame", "Plate2") f:SetPoint("CENTER") - f:SetSize(100, 100) + f:SetWidth(100); f:SetHeight(100) f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) f:SetBackdrop(nil) "#, diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index d2fba471..a1d835ba 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -34,7 +34,7 @@ fn button_state_textures_switch_with_interaction() { s.run( r#" local b = CreateFrame("Button", "StateBtn") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\N.blp") b:SetPushedTexture("Interface\\P.blp") b:SetDisabledTexture("Interface\\D.blp") @@ -110,7 +110,7 @@ fn a_state_with_no_texture_leaves_the_shown_one_standing() { s.run( r#" local b = CreateFrame("Button", "StickyBtn") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\N.blp") "#, ) @@ -162,7 +162,7 @@ fn a_state_with_no_texture_leaves_the_shown_one_standing() { s.run( r#" local b = CreateFrame("Button", "BornDeadBtn") - b:SetPoint("BOTTOMLEFT", 200, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 200, 0); b:SetWidth(100); b:SetHeight(100) b:Disable() b:SetNormalTexture("Interface\\N.blp") "#, @@ -191,7 +191,7 @@ fn disabling_a_button_takes_its_whole_highlight_layer() { s.run( r#" local b = CreateFrame("Button", "LayerBtn") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\N.blp") b:SetHighlightTexture("Interface\\H.blp") local own = b:CreateTexture(nil, "HIGHLIGHT") @@ -271,7 +271,7 @@ fn any_registered_mouse_button_shows_the_pushed_texture() { r#" local function slot(name, x) local b = CreateFrame("Button", name) - b:SetPoint("BOTTOMLEFT", x, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", x, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\" .. name .. "N.blp") b:SetPushedTexture("Interface\\" .. name .. "P.blp") return b @@ -331,7 +331,7 @@ fn set_button_state_drives_the_pushed_visual() { s.run( r#" local b = CreateFrame("Button", "PushBtn") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\N.blp") b:SetPushedTexture("Interface\\P.blp") "#, @@ -375,7 +375,7 @@ fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() { r#" clicks, seen_checked = 0, nil local cb = CreateFrame("CheckButton", "Toggler") - cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetSize(100, 100) + cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetWidth(100); cb:SetHeight(100) cb:SetScript("OnClick", function(self, button, down) clicks = clicks + 1 seen_checked = self:GetChecked() @@ -422,7 +422,7 @@ fn default_registration_is_left_click_only_right_click_reaches_nothing() { r#" clicks = 0 local btn = CreateFrame("Button", "Vendor") - btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetSize(100, 100) + btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetWidth(100); btn:SetHeight(100) btn:SetScript("OnClick", function(self, button, down) clicks = clicks + 1 end) "#, ) @@ -445,7 +445,7 @@ fn register_for_clicks_grows_right_click_and_carries_the_button_name() { r#" clicks, click_btn, arg1_btn = 0, nil, nil local btn = CreateFrame("Button", "Vendor") - btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetSize(100, 100) + btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetWidth(100); btn:SetHeight(100) btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") btn:SetScript("OnClick", function(self, button, down) clicks = clicks + 1 @@ -478,7 +478,7 @@ fn down_registration_fires_on_press_and_toggles_checked_once() { r#" clicks, seen_down = 0, nil local cb = CreateFrame("CheckButton", "QuickSell") - cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetSize(100, 100) + cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetWidth(100); cb:SetHeight(100) cb:RegisterForClicks("LeftButtonDown") cb:SetScript("OnClick", function(self, button, down) clicks = clicks + 1 @@ -509,9 +509,9 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() { s.run( r#" local b = CreateFrame("Button", "AddBtn") - b:SetPoint("BOTTOMLEFT", 100, 100); b:SetSize(36, 36) + b:SetPoint("BOTTOMLEFT", 100, 100); b:SetWidth(36); b:SetHeight(36) b:SetNormalTexture("Interface\\Ring.blp") - b:GetNormalTexture():SetSize(64, 64) + local nt = b:GetNormalTexture(); nt:SetWidth(64); nt:SetHeight(64) b:SetHighlightTexture("Interface\\Hi.blp") "#, ) @@ -533,7 +533,7 @@ fn highlight_is_additive_and_state_textures_fill_then_anchor() { )); // A fresh state texture gets the creation-path implicit SetAllPoints (decision 1310 — the // reference's string setters anchor a freshly built texture to the button outright), whose - // two corners pin all four edges: the later SetSize(64) is structurally unread and the ring + // two corners pin all four edges: the later 64px size is structurally unread and the ring // FILLS the 36px button. let r = find("Interface\\Ring.blp").rect.unwrap(); assert_eq!( @@ -608,7 +608,7 @@ fn button_label_repaints_by_state_font_object() { s.run( r#" b = CreateFrame("Button", "FontBtn") - b:SetPoint("CENTER", 0, 0); b:SetSize(100, 20) + b:SetPoint("CENTER", 0, 0); b:SetWidth(100); b:SetHeight(20) b:SetText("Label") b:SetTextFontObject("GoldFont") b:SetDisabledFontObject("GrayFont") @@ -696,7 +696,7 @@ fn a_locked_or_hovered_button_wears_its_highlight_font_over_its_normal_color() { s.run( r#" b = CreateFrame("Button", "RowBtn") - b:SetPoint("BOTTOMLEFT", 100, 100); b:SetSize(100, 20) + b:SetPoint("BOTTOMLEFT", 100, 100); b:SetWidth(100); b:SetHeight(20) b:SetText("Rough Copper Vest") b:SetTextFontObject("RowNormal") b:SetHighlightFontObject("RowHighlight") @@ -1103,7 +1103,7 @@ fn a_button_labels_own_setfont_survives_the_state_font_repoint() { s.run( r#" b = CreateFrame("Button", "SkinnedBtn") - b:SetPoint("CENTER", 0, 0); b:SetSize(100, 20) + b:SetPoint("CENTER", 0, 0); b:SetWidth(100); b:SetHeight(20) b:SetText("Label") b:SetTextFontObject("TemplateFont") b:GetFontString():SetFont("Interface\\Addons\\Skin\\Fonts\\porky.ttf", 18, "OUTLINE") @@ -1189,7 +1189,7 @@ fn a_state_texture_slot_takes_an_object_and_a_nil() { s.run( r#" b = CreateFrame("Button", "SlotBtn") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\N.blp") -- Bongos' own idiom: build the highlight yourself and hand the object over. hl = b:CreateTexture() @@ -1260,7 +1260,7 @@ fn an_unlocked_scripted_push_is_released_by_the_next_mouse_release() { s.run( r#" row = CreateFrame("Button", "TabletRow") - row:SetPoint("BOTTOMLEFT", 0, 0); row:SetSize(100, 100) + row:SetPoint("BOTTOMLEFT", 0, 0); row:SetWidth(100); row:SetHeight(100) row:SetNormalTexture("Interface\\RowN.blp") row:SetPushedTexture("Interface\\RowP.blp") row:SetButtonState("PUSHED") -- Tablet-2.0's call, verbatim: no lock argument @@ -1301,10 +1301,10 @@ fn a_locked_state_ignores_the_mouse_and_enable_disable_clears_the_lock() { s.run( r#" micro = CreateFrame("Button", "MicroButton") - micro:SetPoint("BOTTOMLEFT", 0, 0); micro:SetSize(100, 100) + micro:SetPoint("BOTTOMLEFT", 0, 0); micro:SetWidth(100); micro:SetHeight(100) micro:SetButtonState("PUSHED", 1) -- MainMenuBarMicroButtons.lua, verbatim pin = CreateFrame("Button", "PinnedNormal") - pin:SetPoint("BOTTOMLEFT", 200, 0); pin:SetSize(100, 100) + pin:SetPoint("BOTTOMLEFT", 200, 0); pin:SetWidth(100); pin:SetHeight(100) pin:SetButtonState("NORMAL", 1) "#, ) @@ -1367,7 +1367,7 @@ fn the_hover_is_not_an_input_to_the_press_state() { s.run( r#" b = CreateFrame("Button", "HeldButton") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100) b:SetNormalTexture("Interface\\HeldN.blp") b:SetPushedTexture("Interface\\HeldP.blp") "#, @@ -1416,7 +1416,7 @@ fn hiding_a_held_button_un_presses_it() { s.run( r#" h = CreateFrame("Button", "HidButton") - h:SetPoint("BOTTOMLEFT", 0, 0); h:SetSize(100, 100) + h:SetPoint("BOTTOMLEFT", 0, 0); h:SetWidth(100); h:SetHeight(100) "#, ) .unwrap(); @@ -1438,3 +1438,86 @@ fn hiding_a_held_button_un_presses_it() { s.mouse_button(50.0, 50.0, "LeftButton", false); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// `Button:GetTextColor()` — **FOUR** values, r/g/b/a (`0x781100`, table `0x879d00`, argc 1, +/// arity 4, kinds `(number,number,number,number)`). Three is the plausible wrong answer, and the +/// shapes table flags the name `name-not-unique` because seven tables register it — this asserts +/// the BUTTON one, on a Button, and its two inheritance legs. +/// +/// Completeness rather than a live break: no corpus site has a Button receiver today (every +/// measured `GetTextColor` is on a FontString or a font object). It is here because the reference +/// registers it, so the widget shape gate can cover it from now on. +#[test] +fn button_get_text_color_answers_four_values_through_the_state_font() { + let s = script(); + s.register_font_object( + "BtnGold", + FontObject { + color: Some([1.0, 0.82, 0.0, 1.0]), + height: Some(12.0), + ..Default::default() + }, + ); + s.run( + r#" + Plain = CreateFrame("Button", "PlainBtn") + Themed = CreateFrame("Button", "ThemedBtn") + Themed:SetTextFontObject("BtnGold") + "#, + ) + .unwrap(); + + assert_eq!( + s.eval::("return select('#', PlainBtn:GetTextColor())") + .unwrap(), + 4, + "arity 4 — not 3, the plausible wrong answer" + ); + let kinds: String = s + .eval( + r#"local r,g,b,a = PlainBtn:GetTextColor() + return type(r)..","..type(g)..","..type(b)..","..type(a)"#, + ) + .unwrap(); + assert_eq!(kinds, "number,number,number,number"); + + // A button with nothing set anywhere is the untinted white every other colour getter here + // (`GetVertexColor`, `FontString:GetTextColor`) answers. + let plain: Vec = (1..=4) + .map(|i| { + s.eval::(&format!("return select({i}, PlainBtn:GetTextColor())")) + .unwrap() + }) + .collect(); + assert_eq!(plain, vec![1.0, 1.0, 1.0, 1.0]); + + // With no local colour it reads THROUGH what the normal state inherits — the same leg + // `Button:GetFont` takes, and the one a stock `GameMenuButtonTemplate` button relies on. + let themed: Vec = (1..=4) + .map(|i| { + s.eval::(&format!("return select({i}, ThemedBtn:GetTextColor())")) + .unwrap() + }) + .collect(); + assert_eq!(themed, vec![1.0, 0.82, 0.0, 1.0]); + + // A local SetTextColor wins, alpha included, and round-trips. + s.run("ThemedBtn:SetTextColor(0.1, 0.2, 0.3, 0.4)").unwrap(); + let set: Vec = (1..=4) + .map(|i| { + s.eval::(&format!("return select({i}, ThemedBtn:GetTextColor())")) + .unwrap() + }) + .collect(); + assert_eq!(set, vec![0.1, 0.2, 0.3, 0.4]); + + // A CheckButton reaches it through Button's table, as it does the rest of the trio. + assert_eq!( + s.eval::( + r#"local c = CreateFrame("CheckButton", "ChkColorBtn") + return select('#', c:GetTextColor())"# + ) + .unwrap(), + 4 + ); +} diff --git a/crates/benilla-ui/src/script/tests/end_to_end.rs b/crates/benilla-ui/src/script/tests/end_to_end.rs index 1fcfd680..b2d5ee21 100644 --- a/crates/benilla-ui/src/script/tests/end_to_end.rs +++ b/crates/benilla-ui/src/script/tests/end_to_end.rs @@ -14,7 +14,7 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() { -- same strata/level) with a texture and a fontstring. ~30 lines of ordinary FrameXML Lua. local parent = CreateFrame("Frame", "Root") parent:SetPoint("TOPLEFT", 0, 0) -- anchored to the screen root - parent:SetSize(400, 300) + parent:SetWidth(400); parent:SetHeight(300) -- SetAllPoints on each region: a templateless Lua region gets NO implicit anchor -- (decision 1310 — rect-less, never drawn), so real addon code anchors it, and so do we. local pbg = parent:CreateTexture(nil, "BACKGROUND") @@ -23,7 +23,7 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() { local child = CreateFrame("Frame", "Leaf", parent) child:SetPoint("TOPLEFT", parent, "TOPLEFT", 10, -10) - child:SetSize(100, 50) + child:SetWidth(100); child:SetHeight(50) local cbg = child:CreateTexture(nil, "ARTWORK") cbg:SetTexture("Interface\\Child.blp") cbg:SetAllPoints() diff --git a/crates/benilla-ui/src/script/tests/events.rs b/crates/benilla-ui/src/script/tests/events.rs index f0a02edf..f6461ec7 100644 --- a/crates/benilla-ui/src/script/tests/events.rs +++ b/crates/benilla-ui/src/script/tests/events.rs @@ -270,3 +270,112 @@ fn mid_dispatch_removal_of_the_next_stops_and_append_is_visited() { "a tail-append during dispatch is still visited this dispatch" ); } + +/// `Frame:RegisterAllEvents()` — the frame's `OnEvent` receives every event dispatched +/// (`0x774c20`, table `0x878ec0`, argc 1, arity 0), and `UnregisterAllEvents` clears that state +/// along with the per-event ones. +/// +/// **The clearing half is the load-bearing one.** AceEvent-2.0 — shipped by 63 vanilla addons and +/// 6 of the top 20 — turns all-events on once (`AceEvent.frame:RegisterAllEvents()`), deliberately +/// stops calling `frame:UnregisterEvent(event)` while it is on, and gets back to per-event +/// registration by calling `frame:UnregisterAllEvents()` and then re-registering each event it +/// still wants. A `RegisterAllEvents` that survived that call would leave every Ace2 addon on the +/// whole event stream for the session. +#[test] +fn register_all_events_takes_every_event_and_unregister_all_clears_it() { + let mut s = script(); + s.run( + r#" + seen = {} + All = CreateFrame("Frame", "AllEv") + All:SetScript("OnEvent", function() table.insert(seen, event) end) + "#, + ) + .unwrap(); + + // Arity 0 — the verb answers nothing. + assert_eq!( + s.eval::("return select('#', All:RegisterAllEvents())") + .unwrap(), + 0 + ); + + // Anything dispatched now reaches it, including an event no name list could have enumerated. + for ev in [ + "PLAYER_LOGIN", + "UNIT_HEALTH", + "SOME_SERVER_EVENT_NOBODY_LISTED", + ] { + s.fire_event(ev, vec![]); + } + assert_eq!( + s.eval::("return table.getn(seen)").unwrap(), + 3, + "every event dispatched, whatever its name" + ); + assert_eq!( + s.eval::("return seen[3]").unwrap(), + "SOME_SERVER_EVENT_NOBODY_LISTED" + ); + + // Registering twice is a no-op, and a frame holding BOTH an all-events registration and a + // RegisterEvent for the same event is one listener, not two — the same rule `RegisterEvent`'s + // own `if not already in the list` holds one level down. + s.run(r#"seen = {}; All:RegisterAllEvents(); All:RegisterEvent("UNIT_HEALTH")"#) + .unwrap(); + s.fire_event("UNIT_HEALTH", vec![]); + assert_eq!( + s.eval::("return table.getn(seen)").unwrap(), + 1, + "fired once, not twice" + ); + + // UnregisterAllEvents clears BOTH: the explicit UNIT_HEALTH registration and the all-events one. + s.run("seen = {}; All:UnregisterAllEvents()").unwrap(); + for ev in ["UNIT_HEALTH", "PLAYER_LOGIN"] { + s.fire_event(ev, vec![]); + } + assert_eq!( + s.eval::("return table.getn(seen)").unwrap(), + 0, + "the all-events registration does not outlive UnregisterAllEvents" + ); + + // …and the AceEvent path back: re-register the individual events it still wants. + s.run(r#"All:RegisterEvent("UNIT_HEALTH")"#).unwrap(); + for ev in ["UNIT_HEALTH", "PLAYER_LOGIN"] { + s.fire_event(ev, vec![]); + } + assert_eq!( + s.eval::("return table.getn(seen)").unwrap(), + 1, + "back to exactly one event" + ); + assert!(s.take_errors().is_empty()); +} + +/// An all-events frame dispatches AFTER the event's own listeners — where it would sit if the +/// registration were expanded into every per-event list, since it joined later than they did. +/// Cross-frame order is a law consumers depend on (the two ZoneText frames writing one FontString); +/// it does not stop being one because a listener asked for everything. +#[test] +fn an_all_events_listener_runs_after_the_events_own() { + let mut s = script(); + s.run( + r#" + order = {} + Named = CreateFrame("Frame", "NamedEv") + Named:SetScript("OnEvent", function() table.insert(order, "named") end) + Named:RegisterEvent("PLAYER_LOGIN") + + Everything = CreateFrame("Frame", "EveryEv") + Everything:SetScript("OnEvent", function() table.insert(order, "all") end) + Everything:RegisterAllEvents() + "#, + ) + .unwrap(); + s.fire_event("PLAYER_LOGIN", vec![]); + assert_eq!(s.eval::("return order[1]").unwrap(), "named"); + assert_eq!(s.eval::("return order[2]").unwrap(), "all"); + assert_eq!(s.eval::("return table.getn(order)").unwrap(), 2); +} diff --git a/crates/benilla-ui/src/script/tests/frame_api.rs b/crates/benilla-ui/src/script/tests/frame_api.rs index 0a89246d..c1ce8bfc 100644 --- a/crates/benilla-ui/src/script/tests/frame_api.rs +++ b/crates/benilla-ui/src/script/tests/frame_api.rs @@ -1,9 +1,15 @@ -//! Small frame-API wins: GetLocale, SetAllPoints, SetFormattedText, SetShown, and GetPoint readback. +//! Small frame-API wins: GetLocale, SetAllPoints, and GetPoint readback. use super::common::script; +/// Two era names, asserted together because neither earns a file of its own. +/// +/// This test carried two more legs until decision 2142, `SetFormattedText` and `SetShown`, and +/// both went with the verbs: neither is in a 1.12 method table. What the first of them actually +/// proved — that `%N$s` reorders rather than consumes — is `string.format`'s own behaviour and is +/// pinned where it lives, in [`crate::strings`]. #[test] -fn consensus_api_small_wins() { +fn get_locale_and_set_all_points() { let mut s = script(); s.set_screen_size(800.0, 600.0); // GetLocale — benilla is enUS-data-only. @@ -12,13 +18,11 @@ fn consensus_api_small_wins() { s.run( r#" p = CreateFrame("Frame", "SAP_Parent") - p:SetPoint("BOTTOMLEFT", 100, 100); p:SetSize(200, 50) + p:SetPoint("BOTTOMLEFT", 100, 100); p:SetWidth(200); p:SetHeight(50) c = CreateFrame("Frame", "SAP_Child", p) c:SetAllPoints() -- default: the parent f = CreateFrame("Frame", "SAP_Free") f:SetAllPoints("SAP_Parent") -- by name - fs = p:CreateFontString(nil, "OVERLAY") - fs:SetFormattedText("%2$s %1$s (%3$d)", "world", "hello", 7) "#, ) .unwrap(); @@ -34,20 +38,6 @@ fn consensus_api_small_wins() { ) .unwrap(); assert!(ok, "SetAllPoints matched the target rect"); - - // SetFormattedText routes through the positional-aware format. - assert_eq!( - s.eval::("return fs:GetText()").unwrap(), - "hello world (7)" - ); - - // SetShown: truthy shows, nil/false hides. - s.run("p:SetShown(false)").unwrap(); - assert!(!s.eval::("return p:IsShown()").unwrap()); - s.run("p:SetShown(1)").unwrap(); - assert!(s.eval::("return p:IsShown()").unwrap()); - s.run("p:SetShown(nil)").unwrap(); - assert!(!s.eval::("return p:IsShown()").unwrap()); assert!(s.errors().is_empty(), "{:?}", s.errors()); } diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs index 6a9409e2..78eac0d2 100644 --- a/crates/benilla-ui/src/script/tests/input.rs +++ b/crates/benilla-ui/src/script/tests/input.rs @@ -12,10 +12,10 @@ fn enable_mouse_gates_hit_testing() { -- Two full-screen frames. `b` is created later (drawn on top) but mouse-disabled, so it is -- transparent to hits; the enabled frame behind it (`a`) must capture. local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true) a:SetScript("OnEnter", function(self) who = self:GetName() end) local b = CreateFrame("Frame", "B") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(false) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(false) b:SetScript("OnEnter", function(self) who = self:GetName() end) -- 1/nil, not a boolean (1830): this is the exact comparison shape that inverts, so the -- test asserts it rather than leaning on truthiness. @@ -51,10 +51,10 @@ fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() { r#" who = nil local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true) a:SetScript("OnEnter", function(self) who = self:GetName() end) local b = CreateFrame("Frame", "B") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(true) b:SetScript("OnEnter", function(self) who = self:GetName() end) "#, ) @@ -96,7 +96,7 @@ fn mouse_move_fires_enter_then_leave_across_a_boundary_with_correct_self() { enters, leaves = 0, 0 enter_self_ok, leave_self_ok = false, false local a = CreateFrame("Frame", "A") -- left half only - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:SetScript("OnEnter", function(self) enters = enters + 1; enter_self_ok = (self == a) end) a:SetScript("OnLeave", function(self) leaves = leaves + 1; leave_self_ok = (self == a) end) "#, @@ -127,10 +127,10 @@ fn onclick_fires_on_press_release_same_frame_not_when_release_lands_elsewhere() r#" clicks_a, clicks_b, click_btn = 0, 0, nil local a = CreateFrame("Frame", "A") -- left half - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) a:SetScript("OnClick", function(self, button, down) clicks_a = clicks_a + 1; click_btn = button end) local b = CreateFrame("Frame", "B") -- right half - b:SetPoint("BOTTOMLEFT", 400, 0); b:SetSize(400, 600); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true) b:SetScript("OnClick", function(self, button, down) clicks_b = clicks_b + 1 end) "#, ) @@ -168,14 +168,14 @@ fn hidden_or_effective_hidden_frame_never_captures() { entered = false -- own shown = false local own = CreateFrame("Frame", "OwnHidden") - own:SetPoint("BOTTOMLEFT", 0, 0); own:SetSize(800, 600); own:EnableMouse(true) + own:SetPoint("BOTTOMLEFT", 0, 0); own:SetWidth(800); own:SetHeight(600); own:EnableMouse(true) own:SetScript("OnEnter", function() entered = true end) own:Hide() -- effective-hidden: child is shown but its parent is hidden local parent = CreateFrame("Frame", "Par") - parent:SetPoint("BOTTOMLEFT", 0, 0); parent:SetSize(800, 600) + parent:SetPoint("BOTTOMLEFT", 0, 0); parent:SetWidth(800); parent:SetHeight(600) local child = CreateFrame("Frame", "Ch", parent) - child:SetPoint("BOTTOMLEFT", 0, 0); child:SetSize(800, 600); child:EnableMouse(true) + child:SetPoint("BOTTOMLEFT", 0, 0); child:SetWidth(800); child:SetHeight(600); child:EnableMouse(true) child:SetScript("OnEnter", function() entered = true end) parent:Hide() "#, @@ -203,7 +203,7 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() { r#" wheel = nil local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true) -- The wheel is its own index and its own flag: SetScript never auto-enables (that law is -- XML-load-time only), so a runtime-created frame needs this explicitly, exactly as it -- needs EnableMouse. Mouse-enablement alone puts nothing in the wheel index. @@ -231,7 +231,7 @@ fn hit_rect_insets_shrink_the_mouse_rect_only() { r#" entered = false local a = CreateFrame("Frame", "A") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true) a:SetScript("OnEnter", function() entered = true end) a:SetHitRectInsets(5, 5, 18, 5) local l, r, t, b = a:GetHitRectInsets() @@ -375,7 +375,7 @@ fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() { r#" clicks, doubles, dblbtn = 0, 0, nil local b = CreateFrame("Button", "DblB") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true) b:SetScript("OnClick", function(self, button) clicks = clicks + 1 end) b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end) "#, @@ -432,7 +432,7 @@ fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() { r#" clicks = 0 local b = CreateFrame("Button", "NoDbl") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true) b:SetScript("OnClick", function() clicks = clicks + 1 end) "#, ) @@ -465,9 +465,9 @@ fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() { f:SetScript("OnDoubleClick", function() doubles = doubles + 1 end) end local a = CreateFrame("Button", "DblA") - a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true); watch(a) + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true); watch(a) local b = CreateFrame("Button", "DblBB") - b:SetPoint("BOTTOMLEFT", 300, 0); b:SetSize(100, 100); b:EnableMouse(true); watch(b) + b:SetPoint("BOTTOMLEFT", 300, 0); b:SetWidth(100); b:SetHeight(100); b:EnableMouse(true); watch(b) "#, ) .unwrap(); @@ -522,7 +522,7 @@ fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() { r#" doubles, dblbtn = 0, nil local b = CreateFrame("Button", "MixDbl") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true) b:RegisterForClicks("LeftButtonUp", "RightButtonUp") b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end) "#, @@ -555,7 +555,7 @@ fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() { r#" doubles = 0 local b = CreateFrame("Button", "LeaveDbl") - b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true) + b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true) b:SetScript("OnDoubleClick", function() doubles = doubles + 1 end) "#, ) @@ -585,7 +585,7 @@ fn set_script_on_double_click_is_accepted_because_something_fires_it() { r#" ran = false Btn = CreateFrame("Button", "DblAccepted") - Btn:SetPoint("BOTTOMLEFT", 0, 0); Btn:SetSize(100, 100); Btn:EnableMouse(true) + Btn:SetPoint("BOTTOMLEFT", 0, 0); Btn:SetWidth(100); Btn:SetHeight(100); Btn:EnableMouse(true) Btn:SetScript("OnDoubleClick", function() ran = true end) "#, ) @@ -625,8 +625,8 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() { "OnAttributeChanged", // 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", + // them — `script::tests::model_clock`. `OnHorizontalScroll` left it with the ScrollFrame's + // horizontal offset pair, which fires it — `script::tests::scrollframe`.) "OnHyperlinkEnter", "OnMessageScrollChanged", "OnInputLanguageChanged", @@ -653,9 +653,9 @@ fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() { r#" log = {} local win = CreateFrame("Frame", "Win") - win:SetPoint("BOTTOMLEFT", 0, 0); win:SetSize(800, 600) + win:SetPoint("BOTTOMLEFT", 0, 0); win:SetWidth(800); win:SetHeight(600) local slot = CreateFrame("Button", "Slot", win) - slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetSize(100, 100); slot:EnableMouse(true) + slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetWidth(100); slot:SetHeight(100); slot:EnableMouse(true) slot:SetScript("OnLeave", function() table.insert(log, "leave") end) slot:SetScript("OnHide", function() table.insert(log, "hide") end) "#, @@ -682,7 +682,7 @@ fn hiding_an_unhovered_frame_fires_no_onleave() { r#" leaves = 0 local a = CreateFrame("Button", "Away") - a:SetPoint("BOTTOMLEFT", 500, 500); a:SetSize(50, 50); a:EnableMouse(true) + a:SetPoint("BOTTOMLEFT", 500, 500); a:SetWidth(50); a:SetHeight(50); a:EnableMouse(true) a:SetScript("OnLeave", function() leaves = leaves + 1 end) "#, ) @@ -706,10 +706,10 @@ fn the_repick_re_hovers_without_a_mouse_move() { r#" entered = {} local under = CreateFrame("Button", "Under") - under:SetPoint("BOTTOMLEFT", 100, 100); under:SetSize(100, 100); under:EnableMouse(true) + under:SetPoint("BOTTOMLEFT", 100, 100); under:SetWidth(100); under:SetHeight(100); under:EnableMouse(true) under:SetScript("OnEnter", function() table.insert(entered, "under") end) local over = CreateFrame("Button", "Over") - over:SetPoint("BOTTOMLEFT", 100, 100); over:SetSize(100, 100); over:EnableMouse(true) + over:SetPoint("BOTTOMLEFT", 100, 100); over:SetWidth(100); over:SetHeight(100); over:EnableMouse(true) over:SetScript("OnEnter", function() table.insert(entered, "over") end) -- Say "on top" with the LEVEL, not with declaration order: at equal levels the sweep takes -- the earlier-linked frame (`Under`), which is the tie law, not what this test is about. @@ -752,7 +752,7 @@ fn two_frames() -> crate::script::UiScript { log = {} local function mk(name, x) local f = CreateFrame("Frame", name) - f:SetPoint("BOTTOMLEFT", x, 200); f:SetSize(200, 200); f:EnableMouse(true) + f:SetPoint("BOTTOMLEFT", x, 200); f:SetWidth(200); f:SetHeight(200); f:EnableMouse(true) f:SetScript("OnMouseDown", function(self) table.insert(log, self:GetName()..":down") end) f:SetScript("OnMouseUp", function(self) table.insert(log, self:GetName()..":up") end) return f diff --git a/crates/benilla-ui/src/script/tests/keyboard.rs b/crates/benilla-ui/src/script/tests/keyboard.rs index cc67d816..f6c05ec2 100644 --- a/crates/benilla-ui/src/script/tests/keyboard.rs +++ b/crates/benilla-ui/src/script/tests/keyboard.rs @@ -184,3 +184,90 @@ fn a_raising_handler_still_consumes_and_is_recorded() { s.errors() ); } + +/// **A `CSimpleEditBox` in the walk is asked about FOCUS, never about a script slot** (§1's vtable +/// table + `0x77a900`). Its ctor registers it in both key buckets, and vtable `0x81c910` replaces +/// `+0x5c`/`+0x60` with overrides that *never chain to the base* — "there is no `call 0x76b760` +/// anywhere in it, so the generic `OnChar` slot `+0x180` is unreachable on an editbox". An +/// unfocused box therefore takes the `0x77a956 xor eax,eax` leg: decline, walk continues. +/// +/// Modelling the box as a plain frame here is not a subtle divergence. Stock +/// `SendMailNameEditBox` carries an XML `` (`SendMailFrame_SendeeAutocomplete`), which +/// auto-enables it, and it registers first in the mail window — so it consumed every keystroke and +/// the send tab's other four boxes took no input at all (decision 2145). +#[test] +fn an_unfocused_editbox_declines_rather_than_eating_its_neighbours_keys() { + let mut s = script(); + s.run( + r#" + decoyChars = 0 + decoy = CreateFrame("EditBox", "KbDecoyBox") + decoy:SetAutoFocus(false) + decoy:EnableKeyboard(true) + decoy:SetScript("OnChar", function() decoyChars = decoyChars + 1 end) + target = CreateFrame("EditBox", "KbTargetBox") + target:SetAutoFocus(false) + target:SetFocus() + "#, + ) + .unwrap(); + assert!(s.char_input("k"), "the focused box consumes"); + assert_eq!( + s.eval::("return KbTargetBox:GetText()").unwrap(), + "k", + "the keystroke reaches the box that holds the focus" + ); + assert_eq!( + s.eval::("return decoyChars").unwrap(), + 0, + "the unfocused box's own OnChar is unreachable from the walk — it declines" + ); + + // Give the decoy the focus and its handler is reachable again — through the INSERT, which is + // where an editbox's `OnChar` is fired from (`0x77c200`), not through the walk's base gate. + s.run("KbDecoyBox:SetFocus()").unwrap(); + assert!(s.char_input("j"), "…and now it is the one that consumes"); + assert_eq!(s.eval::("return decoyChars").unwrap(), 1); + assert_eq!( + s.eval::("return KbDecoyBox:GetText()").unwrap(), + "j" + ); + assert_eq!( + s.eval::("return KbTargetBox:GetText()").unwrap(), + "k", + "and the box that lost focus keeps what it had" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The same clause on the key-down channel (`+0x60` → `0x77b160`, focus guard at `0x77b1c7`), which +/// is the [`super::super::keyboard::frame_key_input`] walk: an unfocused box carrying only an +/// `OnKeyUp` must not swallow a BACKSPACE the focused box is waiting for. The plain-frame +/// asymmetry above is real *for plain frames*; on a box the base gate is not reached at all. +#[test] +fn an_unfocused_editbox_does_not_swallow_a_focused_boxs_editing_key() { + let mut s = script(); + s.run( + r#" + decoy = CreateFrame("EditBox", "KbDecoyBox2") + decoy:SetAutoFocus(false) + decoy:EnableKeyboard(true) + decoy:SetScript("OnKeyUp", function() end) + target = CreateFrame("EditBox", "KbTargetBox2") + target:SetAutoFocus(false) + target:SetText("ab") + target:SetFocus() + "#, + ) + .unwrap(); + s.editbox_action(crate::script::EditAction::Delete { + unit: crate::script::EditUnit::Char, + back: true, + }); + assert_eq!( + s.eval::("return KbTargetBox2:GetText()").unwrap(), + "a", + "the backspace reached the focused box" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index 96448062..0c8d3542 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -187,7 +187,7 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() { s.run( r#" local owner = CreateFrame("Button", "Slot") - owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40) + owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) tt = CreateFrame("GameTooltip", "TT") -- One bag-slot OnEnter: the clear (SetOwner) + the content rebuild, verbatim in shape. function reenter() diff --git a/crates/benilla-ui/src/script/tests/measure.rs b/crates/benilla-ui/src/script/tests/measure.rs index b9ba4766..40e28a3b 100644 --- a/crates/benilla-ui/src/script/tests/measure.rs +++ b/crates/benilla-ui/src/script/tests/measure.rs @@ -14,13 +14,13 @@ fn measured_fontstring_height_feeds_frame_anchors() { s.run( r#" local w = CreateFrame("Frame", "Win") - w:SetPoint("TOPLEFT", 0, -100); w:SetSize(384, 512) + w:SetPoint("TOPLEFT", 0, -100); w:SetWidth(384); w:SetHeight(512) local g = w:CreateFontString("Greeting", "ARTWORK") g:SetText("a long greeting that wraps") g:SetWidth(270) g:SetPoint("TOPLEFT", 33, -91) local row = CreateFrame("Button", "Row1") - row:SetSize(300, 16) + row:SetWidth(300); row:SetHeight(16) row:SetPoint("TOPLEFT", "Greeting", "BOTTOMLEFT", -10, -20) "#, ) @@ -74,7 +74,7 @@ fn a_changed_text_reads_zero_until_its_own_measure_lands() { s.run( r#" local w = CreateFrame("Frame", "Win") - w:SetPoint("TOPLEFT", 0, -100); w:SetSize(384, 512) + w:SetPoint("TOPLEFT", 0, -100); w:SetWidth(384); w:SetHeight(512) local h = w:CreateFontString("Header", "ARTWORK") h:SetText("Say: ") h:SetPoint("LEFT", 13, 0) @@ -129,14 +129,14 @@ fn zero_width_fontstring_autosizes_to_its_line() { s.run( r#" local w = CreateFrame("Frame", "Win") - w:SetPoint("TOPLEFT", 0, -100); w:SetSize(384, 512) + w:SetPoint("TOPLEFT", 0, -100); w:SetWidth(384); w:SetHeight(512) local label = w:CreateFontString("FromLabel", "ARTWORK") label:SetText("From:") - label:SetSize(0, 16) + label:SetWidth(0); label:SetHeight(16) label:SetPoint("TOPRIGHT", "Win", "TOPLEFT", 114, -45) local value = w:CreateFontString("FromValue", "ARTWORK") value:SetText("Thrall") - value:SetSize(110, 0) + value:SetWidth(110); value:SetHeight(0) value:SetPoint("LEFT", "FromLabel", "RIGHT", 5, 0) "#, ) @@ -181,7 +181,7 @@ fn frame_scale_rides_the_quads_and_the_measure_key() { s.run( r#" local w = CreateFrame("Frame", "Win") - w:SetPoint("TOPLEFT", 0, -100); w:SetSize(400, 300) + w:SetPoint("TOPLEFT", 0, -100); w:SetWidth(400); w:SetHeight(300) w:SetScale(0.8) local t = w:CreateFontString("ScaledLabel", "ARTWORK") t:SetText("Options") @@ -234,7 +234,7 @@ fn invalidate_text_measures_reopens_the_round_trip() { s.run( r#" local w = CreateFrame("Frame", "Win") - w:SetPoint("TOPLEFT", 0, -100); w:SetSize(400, 300) + w:SetPoint("TOPLEFT", 0, -100); w:SetWidth(400); w:SetHeight(300) local t = w:CreateFontString("Label", "ARTWORK") t:SetText("Keybindings") t:SetPoint("TOPLEFT", 10, -10) @@ -382,7 +382,7 @@ fn a_width_read_in_the_tick_that_set_the_text_is_not_zero() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetSize(400, 300) + f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") fs:SetPoint("TOPLEFT") -- the corpus idiom: set, then measure, in one statement sequence @@ -408,7 +408,7 @@ fn with_no_engine_installed_the_round_trip_is_still_the_only_answer() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetSize(400, 300) + f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") fs:SetPoint("TOPLEFT") fs:SetText("Onewarrior") @@ -435,7 +435,7 @@ fn resolve_closes_the_round_trip_when_an_engine_is_installed() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetSize(400, 300) + f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") fs:SetPoint("TOPLEFT") fs:SetWidth(35) -- a declared width ⇒ the two extents differ @@ -469,7 +469,7 @@ fn a_synchronous_measure_satisfies_the_batch_request_too() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetSize(400, 300) + f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") fs:SetPoint("TOPLEFT") fs:SetText("hello") @@ -505,7 +505,7 @@ fn a_settled_region_is_refound_after_each_measure_input_write() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetSize(400, 300) + f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") fs:SetPoint("TOPLEFT") fs:SetText("one") @@ -514,7 +514,8 @@ fn a_settled_region_is_refound_after_each_measure_input_write() { .unwrap(); let writes: &[(&str, &str)] = &[ ("SetText", "Label:SetText('two')"), - ("SetFormattedText", "Label:SetFormattedText('n=%d', 3)"), + // `SetFormattedText` was a second row here; it is gone (2142), and its era spelling + // `SetText(format(...))` is the row above's path exactly. ( "SetFont", r#"Label:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")"#, @@ -550,7 +551,7 @@ fn a_settled_region_is_refound_after_each_measure_input_write() { s.run( r#" local b = CreateFrame("Button", "Btn") - b:SetPoint("TOPLEFT", 0, -50); b:SetSize(80, 22) + b:SetPoint("TOPLEFT", 0, -50); b:SetWidth(80); b:SetHeight(22) "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index adf0943f..f8752833 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -49,6 +49,7 @@ mod tooltip_spell; mod tooltip_unit; mod toplevel; mod visibility; +mod widget_surface; mod worldframe; mod worldmap; diff --git a/crates/benilla-ui/src/script/tests/movable.rs b/crates/benilla-ui/src/script/tests/movable.rs index 12f231bc..e256e559 100644 --- a/crates/benilla-ui/src/script/tests/movable.rs +++ b/crates/benilla-ui/src/script/tests/movable.rs @@ -17,7 +17,7 @@ fn movable_panel() -> UiScript { starts, stops = 0, 0 Panel = CreateFrame("Frame", "MovePanel") Panel:SetPoint("BOTTOMLEFT", 100, 100) - Panel:SetSize(200, 80) + Panel:SetWidth(200); Panel:SetHeight(80) Panel:EnableMouse(true) Panel:SetMovable(true) Panel:RegisterForDrag("LeftButton") @@ -113,7 +113,7 @@ fn start_moving_on_a_frame_that_is_not_movable_raises_and_moves_nothing() { r#" Fixed = CreateFrame("Frame", "FixedPanel") Fixed:SetPoint("BOTTOMLEFT", 100, 100) - Fixed:SetSize(200, 80) + Fixed:SetWidth(200); Fixed:SetHeight(80) ok, err = pcall(function() Fixed:StartMoving() end) "#, ) @@ -153,7 +153,7 @@ fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_ r#" Other = CreateFrame("Frame", "OtherPanel") Other:SetPoint("BOTTOMLEFT", 400, 400) - Other:SetSize(50, 50) + Other:SetWidth(50); Other:SetHeight(50) MovePanel:StopMovingOrSizing() -- nothing is moving MovePanel:StopMovingOrSizing() -- twice OtherPanel:StopMovingOrSizing() @@ -191,7 +191,7 @@ fn a_frame_stretched_between_two_anchors_moves_rigidly() { s.run( r#" Back = CreateFrame("Frame", "StretchBack") - Back:SetPoint("BOTTOMLEFT", 0, 0); Back:SetSize(800, 600) + Back:SetPoint("BOTTOMLEFT", 0, 0); Back:SetWidth(800); Back:SetHeight(600) Stretch = CreateFrame("Frame", "StretchPanel", Back) Stretch:SetPoint("BOTTOMLEFT", Back, "BOTTOMLEFT", 100, 100) Stretch:SetPoint("TOPRIGHT", Back, "BOTTOMLEFT", 300, 200) @@ -229,7 +229,7 @@ fn a_scaled_frame_tracks_the_cursor_one_to_one_on_screen() { r#" Scaled = CreateFrame("Frame", "ScaledPanel") Scaled:SetPoint("BOTTOMLEFT", 100, 100) - Scaled:SetSize(100, 100) + Scaled:SetWidth(100); Scaled:SetHeight(100) Scaled:SetScale(2) Scaled:SetMovable(true) Scaled:StartMoving() @@ -266,7 +266,7 @@ fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() { s.run( r#" F = CreateFrame("Frame", "FlagPanel") - F:SetPoint("BOTTOMLEFT", 10, 10); F:SetSize(50, 50) + F:SetPoint("BOTTOMLEFT", 10, 10); F:SetWidth(50); F:SetHeight(50) "#, ) .unwrap(); @@ -425,7 +425,7 @@ fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() { r#" TFrame = CreateFrame("Frame", "TFrame") TFrame:SetPoint("BOTTOMLEFT", 100, 100) - TFrame:SetSize(200, 80) + TFrame:SetWidth(200); TFrame:SetHeight(80) "#, ) .unwrap(); @@ -549,7 +549,7 @@ fn a_title_region_drag_swallows_the_press_and_ends_on_release() { downs = 0 TP = CreateFrame("Frame", "TP") TP:SetPoint("BOTTOMLEFT", 100, 100) - TP:SetSize(200, 80) + TP:SetWidth(200); TP:SetHeight(80) TP:EnableMouse(true) TP:SetScript("OnMouseDown", function() downs = downs + 1 end) TP:CreateTitleRegion():SetAllPoints(TP) diff --git a/crates/benilla-ui/src/script/tests/name_targets.rs b/crates/benilla-ui/src/script/tests/name_targets.rs index 9f94c0e9..e1afb921 100644 --- a/crates/benilla-ui/src/script/tests/name_targets.rs +++ b/crates/benilla-ui/src/script/tests/name_targets.rs @@ -29,10 +29,10 @@ fn setpoint_relative_to_resolves_an_alias_global() { -- The reference UI's own frames. Bar = CreateFrame("Frame", "Bar") Bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 50) - Bar:SetSize(200, 40) + Bar:SetWidth(200); Bar:SetHeight(40) for i = 1, 5 do local b = CreateFrame("Button", "StockButton" .. i, Bar) - b:SetSize(30, 30) + b:SetWidth(30); b:SetHeight(30) end -- The addon's aliases: plain globals, no frame's name (Bartender2's Alias.lua). @@ -84,14 +84,14 @@ fn setpoint_relative_to_resolves_a_region_alias_global() { r#" Panel = CreateFrame("Frame", "Panel") Panel:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0) - Panel:SetSize(400, 300) + Panel:SetWidth(400); Panel:SetHeight(300) local tex = Panel:CreateTexture("PanelSwatch", "ARTWORK") tex:SetPoint("BOTTOMLEFT", "Panel", "BOTTOMLEFT", 10, 20) - tex:SetSize(50, 50) + tex:SetWidth(50); tex:SetHeight(50) Swatch = PanelSwatch -- the alias Tag = CreateFrame("Frame", "Tag", Panel) - Tag:SetSize(10, 10) + Tag:SetWidth(10); Tag:SetHeight(10) Tag:SetPoint("BOTTOMLEFT", "Swatch", "BOTTOMRIGHT", 4, 0) "#, ) @@ -117,10 +117,10 @@ fn setpoint_relative_to_ignores_a_non_widget_global() { r#" Host = CreateFrame("Frame", "Host") Host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 10, 10) - Host:SetSize(100, 100) + Host:SetWidth(100); Host:SetHeight(100) NotAWidget = 5 Child = CreateFrame("Frame", "Child", Host) - Child:SetSize(20, 20) + Child:SetWidth(20); Child:SetHeight(20) Child:SetPoint("BOTTOMLEFT", "NotAWidget", "BOTTOMRIGHT", 0, 0) "#, ) @@ -150,17 +150,17 @@ fn setpoint_relative_to_expands_parent_against_the_first_named_ancestor() { r#" Root = CreateFrame("Frame", "Root") Root:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0) - Root:SetSize(300, 200) + Root:SetWidth(300); Root:SetHeight(200) Peg = CreateFrame("Frame", "RootPeg", Root) Peg:SetPoint("BOTTOMLEFT", "Root", "BOTTOMLEFT", 20, 30) - Peg:SetSize(10, 10) + Peg:SetWidth(10); Peg:SetHeight(10) -- An ANONYMOUS link in between: the walk skips it and lands on Root. Anon = CreateFrame("Frame", nil, Root) Anon:SetAllPoints(Root) Kid = CreateFrame("Frame", "Kid", Anon) - Kid:SetSize(5, 5) + Kid:SetWidth(5); Kid:SetHeight(5) Kid:SetPoint("BOTTOMLEFT", "$parentPeg", "BOTTOMRIGHT", 0, 0) "#, ) @@ -227,7 +227,7 @@ fn setallpoints_resolves_an_alias_global() { r#" Plate = CreateFrame("Frame", "Plate") Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 30, 40) - Plate:SetSize(120, 60) + Plate:SetWidth(120); Plate:SetHeight(60) PlateAlias = Plate Overlay = CreateFrame("Frame", "Overlay") Overlay:SetAllPoints("PlateAlias") diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index a02a47be..3040fc92 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -48,8 +48,15 @@ fn reference() -> Vec<(String, String)> { fn allowed_beyond_1_12() -> HashSet<&'static str> { [ // ── our Lua runtime is 5.1 where 1.12's is 5.0 ──────────────────────────────────────── - // `select` is load-bearing and stays on purpose — our transcribed FrameXML uses it in 16 - // files as the 5.1 spelling of 5.0's implicit `arg` table. + // `select`'s old reason here — "our transcribed FrameXML uses it in 16 files as the 5.1 + // spelling of 5.0's implicit `arg` table" — **was true and is not any more**: 1751's + // stock-first migration retired those files, and `assets/ui`'s seven survivors contain + // zero `select(` call sites (measured 2026-09-09, decision 2142). What keeps it is now a + // different fact, and a weaker one: it is mlua's, not ours to remove without replacing the + // 5.1 varargs it comes with. The corpus reads it as a version probe — + // `pfUI/libs/libpredict.lua:141` and `ShaguTweaks/libs/libpredict.lua:82` both branch on + // `elseif select and UnitCastingInfo then`, and only our NOT having `UnitCastingInfo` + // keeps them off the TBC cast path. // // `_G` is the last one inherited rather than chosen: 1.12's base library does not export // it (an addon reaches the globals with `getfenv(0)`, which is what AceLibrary does), but @@ -64,9 +71,38 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { "_G", "select", // ── WoW API past 1.12 — 1188 phase 5's list, and the reason that phase exists ───────── - // Every one of these predates 1189 and is used by our own transcribed FrameXML today. - // Resolving each means either replacing it with its 1.12 equivalent (`UnitPower` → - // `UnitMana`, which 1.12 has and we do not) or recording why it stays. + // **The reason written here was "every one of these is used by our own transcribed + // FrameXML today", and as of 2026-09-09 that is true of exactly ONE of the sixteen** + // (`SubmitChatInput`, in the dev-only `ScriptLogFrame.xml`) — a corpus census measured it + // and this file's own claim was checked against `assets/ui`'s seven remaining files, name + // by name, before this comment was rewritten (decision 2142). 1751's migration retired the + // windows that used the rest; the list outlived its justification, which is exactly how + // 2118 found the modifier keys still answering booleans years after 0068's reason for it + // had gone. + // + // **What they are NOT is load-bearing for the addon corpus, and 2143 corrects 2142 for + // saying so.** The claim written here was that the vanilla ecosystem assumes several of + // these exist — `pfUI/modules/loot.lua` calling `wipe` 25 times and defining it nowhere, + // so ours "makes that module run at all". That is wrong twice over, and the error is + // instructive: **a "1.12 addon" in that corpus is usually a MULTI-CLIENT addon**. pfUI + // ships one codebase for three clients and gates per module — + // `pfUI:RegisterModule("loot", "vanilla:tbc", …)`, matched by + // `strfind(version, pfUI.expansion)` at `pfUI.lua:250` — so a TBC-era habit sits in code + // the vanilla client reaches. The `wipe` calls are in `RequestRolls`, + // `BuildSpecialRecipientsMenu` and `ClearRolls`: the master-looter's roll helper, not the + // loot window, and that path throws on a real 1.12 client too. And `strsplit` was worse — + // pfUI has its own `pfUI.api.strsplit` (`api/api.lua:16`) and ShaguTweaks its own + // `ShaguTweaks.strsplit`; the bare calls are inside the vendored `libs/libpredict.lua`, + // which has TBC branches of its own. + // + // So our superset was HIDING those latent nil-calls, never enabling anything. The one + // real interaction left is the other direction: `AtlasLoot/Core/AtlasLoot.lua:2119`/`:2148` + // define GLOBAL `strsplit`/`strtrim` unconditionally, with incompatible pattern-based + // two-return semantics, and clobber ours for everything loaded after them — so ours is not + // authoritative at runtime either. + // + // Resolving each means replacing it with its 1.12 equivalent (`UnitPower` → `UnitMana`, + // which 1.12 has and we do not), deleting it, or recording why it stays. // // Three left with 1751's gossip work: `GetNumGossipQuests`, `GetGossipQuestInfo` and // `SelectGossipQuest` were benilla's own single-list shape over the gossip packet's quest @@ -332,12 +368,12 @@ fn region_set_parent_relinks_the_draw_owner_and_leaves_anchors_alone() { s.run( r#" A = CreateFrame("Frame", "SPOwnerA") - A:SetPoint("BOTTOMLEFT", 0, 0) A:SetSize(100, 50) + A:SetPoint("BOTTOMLEFT", 0, 0) A:SetWidth(100); A:SetHeight(50) B = CreateFrame("Frame", "SPOwnerB") - B:SetPoint("BOTTOMLEFT", 300, 0) B:SetSize(100, 50) + B:SetPoint("BOTTOMLEFT", 300, 0) B:SetWidth(100); B:SetHeight(50) Spark = A:CreateTexture("SPSpark", "ARTWORK") Spark:SetTexture("Interface\\SPGlow") - Spark:SetSize(24, 24) + Spark:SetWidth(24); Spark:SetHeight(24) Spark:SetPoint("TOPLEFT", 4, -4) "#, ) @@ -1161,7 +1197,7 @@ fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() { s.run( r#" RMOwner = CreateFrame("Frame", "RMOwner") - RMOwner:SetPoint("BOTTOMLEFT", 0, 0) RMOwner:SetSize(100, 50) + RMOwner:SetPoint("BOTTOMLEFT", 0, 0) RMOwner:SetWidth(100); RMOwner:SetHeight(50) RMTex = RMOwner:CreateTexture("RMTex", "ARTWORK") RMStr = RMOwner:CreateFontString("RMStr", "ARTWORK") "#, @@ -1197,9 +1233,9 @@ fn the_region_map_readers_answer_the_way_the_edges_do() { s.run( r#" RRFrame = CreateFrame("Frame", "RRFrame") - RRFrame:SetPoint("BOTTOMLEFT", 100, 200) RRFrame:SetSize(200, 100) + RRFrame:SetPoint("BOTTOMLEFT", 100, 200) RRFrame:SetWidth(200); RRFrame:SetHeight(100) RRPlate = RRFrame:CreateTexture("RRPlate", "ARTWORK") - RRPlate:SetPoint("TOPLEFT", 10, -10) RRPlate:SetSize(50, 20) + RRPlate:SetPoint("TOPLEFT", 10, -10) RRPlate:SetWidth(50); RRPlate:SetHeight(20) -- the sibling-region anchor the real XML uses everywhere RRLabel = RRFrame:CreateFontString("RRLabel", "OVERLAY") RRLabel:SetPoint("LEFT", RRPlate, "RIGHT", 4, 0) diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index 5d539add..bbbb10b4 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -40,10 +40,10 @@ fn region_texture_anchored_topleft_resolves_exact_rect() { r#" local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [bottom 0, left 0, top 50, right 100] - f:SetSize(100, 50) + f:SetWidth(100); f:SetHeight(50) local t = f:CreateTexture("Tex", "ARTWORK") t:SetTexture("Interface\\Icon") - t:SetSize(24, 24) + t:SetWidth(24); t:SetHeight(24) t:SetPoint("TOPLEFT", 4, -4) "#, ) @@ -69,7 +69,7 @@ fn region_fontstring_span_floors_at_one_unit_until_measured() { r#" local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100] - f:SetSize(100, 50) + f:SetWidth(100); f:SetHeight(50) local fs = f:CreateFontString("FS", "ARTWORK") fs:SetText("Name") fs:SetJustifyH("LEFT") @@ -116,7 +116,7 @@ fn a_zero_width_solid_spans_eight_units_and_its_artless_twin_gets_no_rect() { r#" local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100] - f:SetSize(100, 50) + f:SetWidth(100); f:SetHeight(50) Fill = f:CreateTexture("Fill", "BORDER") Fill:SetTexture(1, 1, 1, 1) -- the form: an 8x8 solid Fill:SetWidth(0); Fill:SetHeight(13) @@ -153,10 +153,10 @@ fn templateless_lua_region_without_anchors_never_draws() { r#" local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100] - f:SetSize(100, 50) + f:SetWidth(100); f:SetHeight(50) local t = f:CreateTexture("Tex", "ARTWORK") t:SetTexture("Interface\\Ring") - t:SetSize(24, 24) -- no anchors, and no template ⇒ no rect, no draw + t:SetWidth(24); t:SetHeight(24) -- no anchors, and no template ⇒ no rect, no draw assert(t:GetLeft() == nil, "a rect-less region reads nil edges") "#, ) @@ -178,10 +178,10 @@ fn region_set_all_points_fills_owner() { r#" local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100] - f:SetSize(100, 50) + f:SetWidth(100); f:SetHeight(50) local t = f:CreateTexture("Tex", "ARTWORK") t:SetTexture("Interface\\Fill") - t:SetSize(24, 24) -- size present, but setAllPoints wins + t:SetWidth(24); t:SetHeight(24) -- size present, but setAllPoints wins t:SetAllPoints() "#, ) @@ -205,14 +205,14 @@ fn region_anchors_to_sibling_region_by_name() { s.run( r#" local row = CreateFrame("Button", "Row") - row:SetPoint("BOTTOMLEFT", 100, 100); row:SetSize(153, 44) + row:SetPoint("BOTTOMLEFT", 100, 100); row:SetWidth(153); row:SetHeight(44) -- Plate first: its target doesn't exist yet at SetPoint time in XML order terms; the -- name lookup happens at SetPoint (both exist by then in real loads), the RECT ordering -- is the fixpoint's job. local plate = row:CreateTexture("RowPlate", "BACKGROUND") - plate:SetSize(128, 78) + plate:SetWidth(128); plate:SetHeight(78) local slot = row:CreateTexture("RowSlot", "BACKGROUND") - slot:SetSize(64, 64) + slot:SetWidth(64); slot:SetHeight(64) slot:SetPoint("TOPLEFT", "Row", "TOPLEFT", -13, 13) plate:SetPoint("LEFT", "RowSlot", "RIGHT", -9, -18) "#, @@ -266,9 +266,9 @@ fn set_portrait_texture_binds_unit_token_then_settexture_clears_it() { r#" local f = CreateFrame("Frame", "PFrame") f:SetPoint("TOPLEFT", 0, 0) - f:SetSize(100, 100) + f:SetWidth(100); f:SetHeight(100) local p = f:CreateTexture("PFramePortrait", "BACKGROUND") - p:SetSize(64, 64) + p:SetWidth(64); p:SetHeight(64) p:SetPoint("TOPLEFT", 0, 0) SetPortraitTexture(p, "player") "#, @@ -319,7 +319,7 @@ fn benilla_set_booth_texture_binds_square() { r#" local f = CreateFrame("Frame", "DollFrame") f:SetPoint("TOPLEFT", 0, 0) - f:SetSize(200, 300) + f:SetWidth(200); f:SetHeight(300) local m = f:CreateTexture("DollFrameModel", "ARTWORK") m:SetAllPoints() BenillaSetBoothTexture(m, "paperdoll") @@ -359,11 +359,11 @@ fn long_region_chain_resolves_and_a_frame_binds_to_its_tail() { r#" local f = CreateFrame("Frame", "Book") f:SetPoint("TOPLEFT", 0, 0) - f:SetSize(384, 512) + f:SetWidth(384); f:SetHeight(512) for i = 1, 12 do local t = f:CreateTexture("Line" .. i, "ARTWORK") t:SetTexture("Interface\\Line" .. i) - t:SetSize(300, 10) + t:SetWidth(300); t:SetHeight(10) if i == 1 then t:SetPoint("TOPLEFT", "Book", "TOPLEFT", 5, -5) else @@ -371,7 +371,7 @@ fn long_region_chain_resolves_and_a_frame_binds_to_its_tail() { end end local b = CreateFrame("Button", "TailButton", f) - b:SetSize(147, 41) + b:SetWidth(147); b:SetHeight(41) b:SetPoint("TOPLEFT", "Line12", "BOTTOMLEFT", 0, -6) "#, ) @@ -414,13 +414,13 @@ fn child_frame_layers_regions_render_after_the_fixpoint() { r#" local parent = CreateFrame("Frame", "Win") parent:SetPoint("TOPLEFT", 10, -10) - parent:SetSize(400, 300) + parent:SetWidth(400); parent:SetHeight(300) local child = CreateFrame("Frame", "SubPanel", parent) child:SetPoint("TOPLEFT", "Win", "TOPLEFT", 20, -20) - child:SetSize(200, 100) + child:SetWidth(200); child:SetHeight(100) local fs = child:CreateFontString("SubText", "ARTWORK") fs:SetPoint("TOPLEFT", "SubPanel", "TOPLEFT", 5, -5) - fs:SetSize(150, 12) + fs:SetWidth(150); fs:SetHeight(12) fs:SetText("hello from the child") local tex = child:CreateTexture("SubTex", "BACKGROUND") tex:SetTexture("Interface\\SubFill") @@ -462,7 +462,7 @@ fn region_alpha_is_its_own_and_multiplies_the_owner_frames() { r#" f = CreateFrame("Frame", "AlphaOwner") f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0) - f:SetSize(10, 10) + f:SetWidth(10); f:SetHeight(10) tex = f:CreateTexture("AlphaTex", "ARTWORK") tex:SetTexture("Interface\\Foo") assert(tex:GetAlpha() == 1, "an untouched region is opaque") @@ -895,11 +895,22 @@ fn the_two_region_leaves_answer_their_own_maps() { "GetTexture", "SetTexCoord", "SetBlendMode", + "GetBlendMode", + "SetTexCoordModifiesRect", + "GetTexCoordModifiesRect", "GetVertexColor", ] { assert!(has(&s, "Tex", m), "a Texture answers {m}"); assert!(!has(&s, "Str", m), "a FontString must NOT answer {m}"); } + // `SetRotation` is 1.12's PLAYERMODEL verb (`0x84f1fc`/`0x505f00`), not a region one — it is in + // neither leaf map, and ours used to put it on Texture. + for leaf in ["Tex", "Str"] { + assert!( + !has(&s, leaf, "SetRotation"), + "SetRotation is not a region method" + ); + } // FontString-only. for m in [ "SetText", @@ -1012,7 +1023,7 @@ fn the_size_getters_take_the_author_first_then_the_natural_width_and_wrapped_hei s.run( r#" local f = CreateFrame("Frame", "Owner") - f:SetPoint("BOTTOMLEFT", 0, 0); f:SetSize(100, 50) + f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50) Sized = f:CreateFontString("Sized", "ARTWORK") Sized:SetPoint("TOPLEFT"); Sized:SetWidth(300); Sized:SetText("Name") Auto = f:CreateFontString("Auto", "ARTWORK") @@ -1054,7 +1065,7 @@ fn an_empty_string_reads_back_one_unit_and_a_pending_measure_reads_back_zero() { s.run( r#" local f = CreateFrame("Frame", "Owner") - f:SetPoint("BOTTOMLEFT", 0, 0); f:SetSize(100, 50) + f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50) Pending = f:CreateFontString("Pending", "ARTWORK") Pending:SetPoint("TOPLEFT"); Pending:SetText("Name") Empty = f:CreateFontString("Empty", "ARTWORK") @@ -1080,7 +1091,7 @@ fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() { s.run( r#" local f = CreateFrame("Frame", "Owner") - f:SetPoint("BOTTOMLEFT", 0, 0); f:SetSize(100, 50) + f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50) Art = f:CreateTexture("Art", "ARTWORK") Art:SetTexture("Interface\\Crest"); Art:SetPoint("TOPLEFT") Half = f:CreateTexture("Half", "ARTWORK") @@ -1214,9 +1225,9 @@ fn set_portrait_texture_folds_the_token_to_lowercase() { r#" local f = CreateFrame("Frame", "NFrame") f:SetPoint("TOPLEFT", 0, 0) - f:SetSize(100, 100) + f:SetWidth(100); f:SetHeight(100) local p = f:CreateTexture("NFramePortrait", "BACKGROUND") - p:SetSize(64, 64) + p:SetWidth(64); p:SetHeight(64) p:SetPoint("TOPLEFT", 0, 0) SetPortraitTexture(p, "NPC") "#, @@ -1278,11 +1289,7 @@ fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() { // Never written, and every shape of an empty write, all nil. assert_eq!(text_of(&s, "fresh"), None); - for write in [ - r#"fresh:SetText("")"#, - r#"fresh:SetText(nil)"#, - r#"fresh:SetFormattedText("%s", "")"#, - ] { + for write in [r#"fresh:SetText("")"#, r#"fresh:SetText(nil)"#] { s.run(write).unwrap(); assert_eq!(text_of(&s, "fresh"), None, "after `{write}`"); } @@ -1386,3 +1393,144 @@ fn an_unresolvable_set_texture_keeps_the_art_the_region_had() { s.run("t:SetTexture(nil)").unwrap(); assert!(s.eval::("return t:GetTexture() == nil").unwrap()); } + +/// `Texture:GetBlendMode()` — ONE string, the reference's own spelling, round-tripping whatever +/// `SetBlendMode` was given (`0x79a890`/`0x79a950`, table `0x87c128`, argc 1, arity 1, kinds +/// `(string?)`). +/// +/// The untouched default is asserted alongside the round trip because it is the load-bearing half: +/// `ShaguTweaks/mods/dark-ui-elements.lua:169` guards a recolour with +/// `region.GetBlendMode and region:GetBlendMode() == "ADD"`, so the answer an ordinary texture gives +/// decides whether the reference's additive art is left alone. `"BLEND"` is the CSimpleTexture +/// ctor's `[+0xd0] = 2` (`0x76fc64`). +#[test] +fn get_blend_mode_answers_one_string_and_defaults_to_the_ctors_blend() { + let s = crate::script::UiScript::new().unwrap(); + s.run( + r#" + BlendOwner = CreateFrame("Frame", "BlendOwner") + BTex = BlendOwner:CreateTexture("BTex", "ARTWORK") + "#, + ) + .unwrap(); + + // Arity 1 and kind string, measured the way the shape gate measures it. + assert_eq!( + s.eval::("return select('#', BTex:GetBlendMode())") + .unwrap(), + 1, + "arity 1" + ); + assert_eq!( + s.eval::("return type(BTex:GetBlendMode())") + .unwrap(), + "string", + "kind string" + ); + assert_eq!( + s.eval::("return BTex:GetBlendMode()").unwrap(), + "BLEND", + "an untouched texture is the ctor's mode 2" + ); + + // Every one of the enum's five round-trips, including the three the renderer flattens. + for mode in ["DISABLE", "ALPHAKEY", "BLEND", "ADD", "MOD"] { + s.run(&format!(r#"BTex:SetBlendMode("{mode}")"#)).unwrap(); + assert_eq!( + s.eval::("return BTex:GetBlendMode()").unwrap(), + mode, + "round trip through the setter" + ); + } + // Case-insensitive in, canonical out — the setter coerces, the getter answers the table's name. + s.run(r#"BTex:SetBlendMode("add")"#).unwrap(); + assert_eq!( + s.eval::("return BTex:GetBlendMode()").unwrap(), + "ADD" + ); + // A name outside the enum leaves the mode alone rather than inventing one. + s.run(r#"BTex:SetBlendMode("NOT_A_MODE")"#).unwrap(); + assert_eq!( + s.eval::("return BTex:GetBlendMode()").unwrap(), + "ADD", + "an unknown name changes nothing" + ); + + // A FontString is not a Texture: neither half of the pair is on its map. + assert!(s + .eval::( + r#"local fs = BlendOwner:CreateFontString("BStr", "ARTWORK") + return fs.GetBlendMode == nil and fs.SetBlendMode == nil"# + ) + .unwrap()); +} + +/// `Texture:GetTexCoordModifiesRect()` — the 1.12 predicate return, `1`/`nil` and never a Lua +/// boolean (`0x79c120`, table `0x87c128`, argc 1, arity 1, kinds `(nil) | (number)`; decision 2118). +/// `pfUI/modules/thirdparty-tbc.lua:319` calls it bare on a Texture. +/// +/// The flag's effect on the region's rect is deliberately NOT wired (see +/// `RegionData::tex_coord_modifies_rect`), so this asserts the state and the shape — and asserts +/// the un-wiring too, by checking that setting the flag moves no rect: a future change that wires +/// the geometry will fail here and have to say so. +#[test] +fn tex_coord_modifies_rect_is_one_slash_nil_and_moves_no_rect_yet() { + let mut s = crate::script::UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.run( + r#" + TCMOwner = CreateFrame("Frame", "TCMOwner") + TCMOwner:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + TCMOwner:SetWidth(200); TCMOwner:SetHeight(200) + TCMTex = TCMOwner:CreateTexture("TCMTex", "ARTWORK") + TCMTex:SetTexture("Interface\\TcmArt") + TCMTex:SetPoint("BOTTOMLEFT", TCMOwner, "BOTTOMLEFT", 0, 0) + TCMTex:SetWidth(64); TCMTex:SetHeight(32) + "#, + ) + .unwrap(); + s.resolve(); + let before = region_tex_rect(&s, "TcmArt"); + + assert_eq!( + s.eval::("return select('#', TCMTex:GetTexCoordModifiesRect())") + .unwrap(), + 1, + "arity 1" + ); + assert!( + s.eval::("return TCMTex:GetTexCoordModifiesRect() == nil") + .unwrap(), + "unset is nil, not false" + ); + + s.run("TCMTex:SetTexCoordModifiesRect(1)").unwrap(); + assert!( + s.eval::("return TCMTex:GetTexCoordModifiesRect() == 1") + .unwrap(), + "set is the NUMBER 1, not true" + ); + assert_eq!( + s.eval::("return type(TCMTex:GetTexCoordModifiesRect())") + .unwrap(), + "number", + "kind number, never boolean" + ); + + // The flag is state only: a SetTexCoord under it still leaves the rect where the anchors and + // the size put it. When the geometry leg is built, this assertion is the one that must change. + s.run("TCMTex:SetTexCoord(0, 0.25, 0, 0.5)").unwrap(); + s.resolve(); + assert_eq!( + region_tex_rect(&s, "TcmArt"), + before, + "the flag does not (yet) let SetTexCoord re-derive the region's rect" + ); + + s.run("TCMTex:SetTexCoordModifiesRect(nil)").unwrap(); + assert!( + s.eval::("return TCMTex:GetTexCoordModifiesRect() == nil") + .unwrap(), + "cleared back to nil" + ); +} diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 9082cd76..9d51ebfa 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -36,6 +36,11 @@ fn scrollframe_methods_exist_only_on_scrollframes() { return (type(sf.SetScrollChild) == "function") and (plain.SetScrollChild == nil) and (type(sf.SetVerticalScroll) == "function") and (plain.SetVerticalScroll == nil) and (type(sf.GetVerticalScrollRange) == "function") + -- the horizontal trio lives in the same class table, not on every Frame + and (type(sf.SetHorizontalScroll) == "function") and (plain.SetHorizontalScroll == nil) + and (type(sf.GetHorizontalScroll) == "function") and (plain.GetHorizontalScroll == nil) + and (type(sf.GetHorizontalScrollRange) == "function") + and (plain.GetHorizontalScrollRange == nil) and (type(sf.Show) == "function") -- base methods still reachable through the fallback "#, ) @@ -81,13 +86,13 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() { r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) -- screen top 600 -> frame top 500 - frame:SetSize(300, 200) -- frame bottom 300 + frame:SetWidth(300); frame:SetHeight(200) -- frame bottom 300 local child = CreateFrame("Frame", "Child") -- Authored anchor: relative to the screen (child has no parent) — overridden while scrolled, -- and must be exactly what re-applies once the scroll child is cleared. child:SetPoint("TOPLEFT", 20, -20) - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) local marker = child:CreateTexture(nil, "ARTWORK") marker:SetTexture("marker:child") marker:SetAllPoints() -- templateless Lua regions carry no implicit anchor (decision 1310) @@ -170,10 +175,10 @@ fn set_vertical_scroll_fires_on_vertical_scroll_only_on_a_change() { Fires = {} local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) - frame:SetSize(300, 200) + frame:SetWidth(300); frame:SetHeight(200) frame:SetScript("OnVerticalScroll", function() table.insert(Fires, arg1) end) local child = CreateFrame("Frame", "Child") - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) frame:SetVerticalScroll(0) -- already 0: nothing frame:SetVerticalScroll(40) -- a change @@ -199,9 +204,9 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) - frame:SetSize(300, 200) + frame:SetWidth(300); frame:SetHeight(200) local child = CreateFrame("Frame", "Child") - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) "#, ) @@ -221,7 +226,7 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte ); // A child shorter than the frame: 0, not negative. - s.run("Child:SetSize(300, 150)").unwrap(); + s.run("Child:SetWidth(300); Child:SetHeight(150)").unwrap(); s.resolve(); assert_eq!( s.eval::("return SF:GetVerticalScrollRange()").unwrap(), @@ -251,10 +256,10 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() { r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) - frame:SetSize(300, 200) + frame:SetWidth(300); frame:SetHeight(200) frame:SetScale(0.5) local child = CreateFrame("Frame", "Child", frame) - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) "#, ) @@ -299,9 +304,9 @@ fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() { r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) - frame:SetSize(300, 200) + frame:SetWidth(300); frame:SetHeight(200) local child = CreateFrame("Frame", "Child") - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) seen_v = nil @@ -345,9 +350,9 @@ fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve( r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) - frame:SetSize(300, 200) + frame:SetWidth(300); frame:SetHeight(200) local child = CreateFrame("Frame", "Child") - child:SetSize(300, 200) -- starts exactly frame-height: range 0 + child:SetWidth(300); child:SetHeight(200) -- starts exactly frame-height: range 0 frame:SetScrollChild(child) "#, ) @@ -358,7 +363,7 @@ fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve( r#" seen_hi = nil SF:SetScript("OnScrollRangeChanged", function(self, lo, hi) seen_hi = hi end) - Child:SetSize(300, 600) -- grows well past the frame — all in this ONE tick + Child:SetWidth(300); Child:SetHeight(600) -- grows well past the frame — all in this ONE tick SF:UpdateScrollChildRect() "#, ) @@ -389,10 +394,10 @@ fn extract_clips_the_scroll_childs_whole_subtree_and_leaves_a_sibling_unclipped( r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) -- top 500 - frame:SetSize(300, 200) -- bottom 300, right 300 + frame:SetWidth(300); frame:SetHeight(200) -- bottom 300, right 300 local child = CreateFrame("Frame", "Child") - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) local cm = child:CreateTexture(nil, "ARTWORK") cm:SetTexture("marker:child") @@ -400,14 +405,14 @@ fn extract_clips_the_scroll_childs_whole_subtree_and_leaves_a_sibling_unclipped( -- a grandchild FRAME (a real arena descendant of the scroll child) with its own region local grand = CreateFrame("Frame", "Grand", child) grand:SetPoint("TOPLEFT", child, "TOPLEFT", 0, 0) - grand:SetSize(50, 50) + grand:SetWidth(50); grand:SetHeight(50) local gm = grand:CreateTexture(nil, "ARTWORK") gm:SetTexture("marker:grand") -- a sibling entirely outside the ScrollFrame local sib = CreateFrame("Frame", "Sib") sib:SetPoint("TOPLEFT", 0, 0) - sib:SetSize(50, 50) + sib:SetWidth(50); sib:SetHeight(50) local sm = sib:CreateTexture(nil, "ARTWORK") sm:SetTexture("marker:sib") "#, @@ -441,11 +446,11 @@ fn nested_scrollframes_intersect_their_clip_rects() { r#" local outer = CreateFrame("ScrollFrame", "Outer") outer:SetPoint("TOPLEFT", 0, -50) -- top 550 - outer:SetSize(400, 300) -- bottom 250, right 400 + outer:SetWidth(400); outer:SetHeight(300) -- bottom 250, right 400 local om = outer:CreateTexture(nil, "ARTWORK"); om:SetTexture("marker:outer") local c1 = CreateFrame("Frame", "C1") - c1:SetSize(400, 1000) + c1:SetWidth(400); c1:SetHeight(1000) outer:SetScrollChild(c1) local c1m = c1:CreateTexture(nil, "ARTWORK"); c1m:SetTexture("marker:c1") @@ -454,11 +459,11 @@ fn nested_scrollframes_intersect_their_clip_rects() { -- Outer's right edge — an intersection distinct from either rect alone. local inner = CreateFrame("ScrollFrame", "Inner", c1) inner:SetPoint("TOPLEFT", "C1", "TOPLEFT", 350, -30) - inner:SetSize(200, 150) + inner:SetWidth(200); inner:SetHeight(150) local im = inner:CreateTexture(nil, "ARTWORK"); im:SetTexture("marker:inner") local c2 = CreateFrame("Frame", "C2", inner) - c2:SetSize(200, 500) + c2:SetWidth(200); c2:SetHeight(500) inner:SetScrollChild(c2) local leaf = c2:CreateTexture(nil, "ARTWORK"); leaf:SetTexture("marker:leaf") "#, @@ -514,16 +519,16 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view() r#" local frame = CreateFrame("ScrollFrame", "SF") frame:SetPoint("TOPLEFT", 0, -100) -- top 500 - frame:SetSize(300, 200) -- bottom 300 + frame:SetWidth(300); frame:SetHeight(200) -- bottom 300 frame:EnableMouse(false) -- isolate the button's own clip-gated hit (§5's subject) local child = CreateFrame("Frame", "Child") - child:SetSize(300, 600) + child:SetWidth(300); child:SetHeight(600) frame:SetScrollChild(child) local btn = CreateFrame("Button", "Btn", child) btn:SetPoint("TOPLEFT", "Child", "TOPLEFT", 0, -400) - btn:SetSize(50, 20) + btn:SetWidth(50); btn:SetHeight(20) "#, ) .unwrap(); @@ -568,9 +573,9 @@ fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() s.run( r#" local sf = CreateFrame("ScrollFrame", "SF", UIParent) - sf:SetPoint("TOPLEFT", 0, 0); sf:SetSize(100, 50) + sf:SetPoint("TOPLEFT", 0, 0); sf:SetWidth(100); sf:SetHeight(50) local child = CreateFrame("Frame", "SFChild", sf) - child:SetSize(100, 50); sf:SetScrollChild(child) + child:SetWidth(100); child:SetHeight(50); sf:SetScrollChild(child) local fs = child:CreateFontString("SFText", "ARTWORK") fs:SetPoint("TOPLEFT", 0, 0); fs:SetWidth(70) sf:SetScript("OnScrollRangeChanged", function() RANGE = arg2 end) @@ -586,3 +591,172 @@ fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() "56px of measured text under a 50px frame: the range is known at the call, not a frame later" ); } + +/// The horizontal axis is the vertical one's byte-clone (`0x786d30` against `0x786db0`), so this is +/// the vertical trio's test on the other axis: the offset is stored VERBATIM and re-anchors the +/// child, `OnHorizontalScroll` fires only on a real change, and the range is +/// `max(0, subtreeWidth - frameWidth)`. +/// +/// **The sign is the point.** `0x787100` hands both offsets to `SetPoint(TOPLEFT, self, TOPLEFT, +/// +hScroll, +vScroll)` unnegated, and x grows right — so a positive horizontal pushes the child +/// RIGHT where a positive vertical lifts it. `aux-addon/tabs/search/filter.lua:315-322` corroborates +/// it from the caller's side (it bounds x into `[frameWidth - contentWidth - 10, 0]` and y into +/// `[0, contentHeight - frameHeight]`), which is why it is asserted here rather than assumed. +#[test] +fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + + s.run( + r#" + local frame = CreateFrame("ScrollFrame", "HSF") + frame:SetPoint("TOPLEFT", 0, -100) -- frame left 0, top 500 + frame:SetWidth(300); frame:SetHeight(200) -- frame right 300 + + local child = CreateFrame("Frame", "HChild") + child:SetPoint("TOPLEFT", 20, -20) + child:SetWidth(500); child:SetHeight(200) + local marker = child:CreateTexture(nil, "ARTWORK") + marker:SetTexture("marker:hchild") + marker:SetAllPoints() + + frame:SetScrollChild(child) + hfired = {} + frame:SetScript("OnHorizontalScroll", function() table.insert(hfired, arg1) end) + "#, + ) + .unwrap(); + s.resolve(); + + assert_eq!( + s.eval::("return HSF:GetHorizontalScroll()").unwrap(), + 0.0, + "a fresh ScrollFrame is at 0 on both axes" + ); + assert_eq!( + marker_rect(&s.extract(), "marker:hchild").map(|r| r.left), + Some(0.0), + "horizontal=0 -> child left = frame left" + ); + + // Positive pushes the child RIGHT (the reference's unnegated xOff) … + s.run("HSF:SetHorizontalScroll(40)").unwrap(); + s.resolve(); + assert_eq!( + marker_rect(&s.extract(), "marker:hchild").map(|r| r.left), + Some(40.0), + "a positive horizontal offset moves the child right" + ); + // … and it is the NEGATIVE direction that scrolls right, which is the half aux-addon relies on. + s.run("HSF:SetHorizontalScroll(-60)").unwrap(); + s.resolve(); + assert_eq!( + marker_rect(&s.extract(), "marker:hchild").map(|r| r.left), + Some(-60.0), + "a negative horizontal offset scrolls right" + ); + + // No clamp — the range is 500-300 = 200 and 450 is stored and applied whole (decision 2017's + // law, which is `0x786d30`'s as much as `0x786db0`'s: neither reads the range). + assert_eq!( + s.eval::("return HSF:GetHorizontalScrollRange()") + .unwrap(), + 200.0, + "range = subtree width - frame width" + ); + s.run("HSF:SetHorizontalScroll(450)").unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return HSF:GetHorizontalScroll()").unwrap(), + 450.0, + "stored verbatim, past the range" + ); + + // Arity/kind, the way the shape gate measures them. + assert_eq!( + s.eval::("return select('#', HSF:GetHorizontalScroll())") + .unwrap(), + 1 + ); + assert_eq!( + s.eval::("return select('#', HSF:GetHorizontalScrollRange())") + .unwrap(), + 1 + ); + assert_eq!( + s.eval::("return type(HSF:GetHorizontalScroll())") + .unwrap(), + "number" + ); + assert_eq!( + s.eval::("return type(HSF:GetHorizontalScrollRange())") + .unwrap(), + "number" + ); + assert_eq!( + s.eval::("return select('#', HSF:SetHorizontalScroll(450))") + .unwrap(), + 0, + "the setter answers nothing" + ); + + // The change-gate: four setter calls moved the value (40, -60, 450, and 450 again is not one). + let fired: Vec = (1..=s.eval::("return table.getn(hfired)").unwrap()) + .map(|i| s.eval::(&format!("return hfired[{i}]")).unwrap()) + .collect(); + assert_eq!( + fired, + vec![40.0, -60.0, 450.0], + "OnHorizontalScroll fires once per ACTUAL change, with the new offset" + ); + + // The two axes are independent state, not one offset behind two names. + s.run("HSF:SetVerticalScroll(25)").unwrap(); + assert_eq!( + s.eval::("return HSF:GetHorizontalScroll()").unwrap(), + 450.0 + ); + assert_eq!( + s.eval::("return HSF:GetVerticalScroll()").unwrap(), + 25.0 + ); +} + +/// `UpdateScrollChildRect` notifies `OnScrollRangeChanged(self, xRange, yRange)` — **horizontal +/// first**. Byte-derived, not guessed: the vertical result leaves `0x786e30` on top of the x87 +/// stack, is `fstp`'d first and so pushed deepest, landing as `arg2` (wow-re +/// `scrollframe-offset-and-range-law.md` §3.6 — two of that round's own workers published it the +/// other way round, which is why it is asserted here). `arg1` was a hardcoded `0.0` for as long as +/// there was no horizontal range to put in it. +#[test] +fn update_scroll_child_rect_reports_both_ranges_horizontal_first() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local frame = CreateFrame("ScrollFrame", "BSF") + frame:SetPoint("TOPLEFT", 0, -100) + frame:SetWidth(300); frame:SetHeight(200) + local child = CreateFrame("Frame", "BChild") + child:SetPoint("TOPLEFT", 0, 0) + child:SetWidth(500); child:SetHeight(600) + frame:SetScrollChild(child) + seen = nil + frame:SetScript("OnScrollRangeChanged", function() seen = { arg1, arg2 } end) + "#, + ) + .unwrap(); + s.resolve(); + s.run("BSF:UpdateScrollChildRect()").unwrap(); + assert_eq!( + s.eval::("return seen[1]").unwrap(), + 200.0, + "arg1 is the HORIZONTAL range (500 - 300)" + ); + assert_eq!( + s.eval::("return seen[2]").unwrap(), + 400.0, + "arg2 is the VERTICAL range (600 - 200)" + ); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tests/simplehtml.rs b/crates/benilla-ui/src/script/tests/simplehtml.rs index 44065534..a0061bc0 100644 --- a/crates/benilla-ui/src/script/tests/simplehtml.rs +++ b/crates/benilla-ui/src/script/tests/simplehtml.rs @@ -122,7 +122,7 @@ fn page(s: &UiScript) { f:SetTextColor(0.18, 0.12, 0.06) Page = CreateFrame("SimpleHTML", "Page") Page:SetPoint("TOPLEFT", 0, 0) - Page:SetSize(270, 304) + Page:SetWidth(270); Page:SetHeight(304) Page:SetFontObject("P", ItemTextFontNormal) "#, ) diff --git a/crates/benilla-ui/src/script/tests/size_changed.rs b/crates/benilla-ui/src/script/tests/size_changed.rs index 37697328..8ddb5af0 100644 --- a/crates/benilla-ui/src/script/tests/size_changed.rs +++ b/crates/benilla-ui/src/script/tests/size_changed.rs @@ -20,7 +20,7 @@ fn a_resize_fires_on_size_changed_and_a_move_does_not() { fires, w, h = 0, nil, nil Panel = CreateFrame("Frame", "SizedPanel") Panel:SetPoint("BOTTOMLEFT", 100, 100) - Panel:SetSize(200, 50) + Panel:SetWidth(200); Panel:SetHeight(50) Panel:SetScript("OnSizeChanged", function(self, aw, ah) fires = fires + 1 w = aw h = ah end) @@ -70,7 +70,7 @@ fn an_anchor_driven_resize_fires_it_too() { fires, w = 0, nil Parent = CreateFrame("Frame", "SizeParent") Parent:SetPoint("BOTTOMLEFT", 0, 0) - Parent:SetSize(300, 200) + Parent:SetWidth(300); Parent:SetHeight(200) Child = CreateFrame("Frame", "SizeChild", Parent) Child:SetPoint("BOTTOMLEFT", Parent, "BOTTOMLEFT", 0, 0) Child:SetPoint("TOPRIGHT", Parent, "TOPRIGHT", 0, 0) @@ -107,7 +107,7 @@ fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() { fires = 0 Sq = CreateFrame("Frame", "SquarePanel") Sq:SetPoint("BOTTOMLEFT", 0, 0) - Sq:SetSize(200, 50) + Sq:SetWidth(200); Sq:SetHeight(50) -- The idiom: "keep me square". It writes back into the very input that fired it. Sq:SetScript("OnSizeChanged", function(self, aw, ah) fires = fires + 1 @@ -148,7 +148,7 @@ fn set_script_on_size_changed_is_accepted_because_the_resolve_pass_fires_it() { ran = false Accepted = CreateFrame("Frame", "SizeAccepted") Accepted:SetPoint("BOTTOMLEFT", 0, 0) - Accepted:SetSize(10, 10) + Accepted:SetWidth(10); Accepted:SetHeight(10) Accepted:SetScript("OnSizeChanged", function() ran = true end) "#, ) @@ -180,7 +180,7 @@ fn the_on_size_changed_watch_list_is_exactly_the_frames_carrying_the_script() { fires = 0 Watched = CreateFrame("Frame", "WatchedPanel") Watched:SetPoint("BOTTOMLEFT", 0, 0) - Watched:SetSize(10, 10) + Watched:SetWidth(10); Watched:SetHeight(10) Other = CreateFrame("Frame", "UnwatchedPanel") Other:SetScript("OnShow", function() end) -- another kind never enrols local bump = function() fires = fires + 1 end @@ -199,7 +199,8 @@ fn the_on_size_changed_watch_list_is_exactly_the_frames_carrying_the_script() { assert_eq!(watched(&s), 1, "one frame carries the script"); s.resolve(); - s.run("Watched:SetSize(40, 40)").unwrap(); + s.run("Watched:SetWidth(40); Watched:SetHeight(40)") + .unwrap(); s.resolve(); assert_eq!( s.eval::("return fires").unwrap(), diff --git a/crates/benilla-ui/src/script/tests/slider.rs b/crates/benilla-ui/src/script/tests/slider.rs index 085ad4e4..d3868348 100644 --- a/crates/benilla-ui/src/script/tests/slider.rs +++ b/crates/benilla-ui/src/script/tests/slider.rs @@ -27,10 +27,10 @@ fn slider_thumb_draws_at_value_fraction_along_the_track() { local sl = CreateFrame("Slider", "SlRender") -- A vertical scrollbar: 16 wide, 100 tall, bottom-left at (100, 100) -> track y in [100, 200]. sl:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) - sl:SetSize(16, 100) + sl:SetWidth(16); sl:SetHeight(100) sl:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob") local t = sl:GetThumbTexture() - t:SetSize(16, 16) + t:SetWidth(16); t:SetHeight(16) sl:SetMinMaxValues(0, 100) sl:SetValue(0) "#, @@ -73,9 +73,9 @@ fn slider_thumb_drag_maps_cursor_to_value() { r#" bar = CreateFrame("Slider", "SlDrag") bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) - bar:SetSize(16, 100) + bar:SetWidth(16); bar:SetHeight(100) bar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob") - bar:GetThumbTexture():SetSize(16, 16) + local thumb = bar:GetThumbTexture(); thumb:SetWidth(16); thumb:SetHeight(16) bar:SetMinMaxValues(0, 100) bar:SetValue(0) "#, @@ -104,16 +104,16 @@ fn slider_thumb_drag_maps_cursor_to_value() { } #[test] -fn slider_track_press_seats_the_thumb_and_a_disabled_slider_ignores_it() { +fn slider_track_press_seats_the_thumb_and_a_mouseless_slider_ignores_it() { let mut s = script(); s.set_screen_size(1024.0, 768.0); s.run( r#" bar = CreateFrame("Slider", "SlTrack") bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) - bar:SetSize(16, 100) + bar:SetWidth(16); bar:SetHeight(100) bar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob") - bar:GetThumbTexture():SetSize(16, 16) + local thumb = bar:GetThumbTexture(); thumb:SetWidth(16); thumb:SetHeight(16) bar:SetMinMaxValues(0, 100) bar:SetValue(0) fired = {} @@ -142,18 +142,25 @@ fn slider_track_press_seats_the_thumb_and_a_disabled_slider_ignores_it() { assert_eq!(v, 100.0, "the gesture drags on without re-grabbing"); s.mouse_button(108.0, 108.0, "LeftButton", false); - // A disabled slider ignores a press anywhere — thumb and track alike. - s.run(r#"SlTrack:SetValue(0); SlTrack:Disable(); fired = {}"#) + // A slider the mouse cannot reach ignores a press anywhere — thumb and track alike. This was + // `SlTrack:Disable()` until the Button-only `Enable`/`Disable`/`IsEnabled` trio came off the + // Slider table (1.12 registers them on `0x879d00` alone, and the Slider's LoadXML has no + // `enabled` attribute either). `EnableMouse(false)` is how the reference keeps a slider off a + // press, and it gates upstream in the hit test rather than inside `begin_drag`. + s.run(r#"SlTrack:SetValue(0); SlTrack:EnableMouse(false); fired = {}"#) .unwrap(); for y in [192.0, 150.0] { s.mouse_button(108.0, y, "LeftButton", true); s.mouse_move(108.0, 130.0); let v: f32 = s.eval("return SlTrack:GetValue()").unwrap(); - assert_eq!(v, 0.0, "disabled slider does not move (press at y={y})"); + assert_eq!( + v, 0.0, + "mouse-disabled slider does not move (press at y={y})" + ); s.mouse_button(108.0, 130.0, "LeftButton", false); } let n: usize = s.eval("return table.getn(fired)").unwrap(); - assert_eq!(n, 0, "disabled: no OnValueChanged at all"); + assert_eq!(n, 0, "mouse disabled: no OnValueChanged at all"); } #[test] @@ -235,17 +242,30 @@ fn slider_setvalue_fires_onvaluechanged_only_on_change() { .unwrap(); } +/// `Enable`/`Disable`/`IsEnabled` are a **Button** trio in 1.12 (table `0x879d00`), and a Slider +/// must not answer them: a duck-typing addon that branches on `if widget.IsEnabled then` reads a +/// superset as the wrong class (1189/1250 §5). This asserts the removal *and* its control — the +/// same three names still present on a Button, so a regression that emptied the Button table would +/// fail here rather than pass by accident. #[test] -fn slider_enable_disable_roundtrips() { +fn a_slider_does_not_answer_the_buttons_enable_trio() { let s = script(); s.run( r#" local sl = CreateFrame("Slider", "SlEnable") - assert(sl:IsEnabled(), "enabled by ctor") - sl:Disable() - assert(not sl:IsEnabled(), "disabled") - sl:Enable() - assert(sl:IsEnabled(), "re-enabled") + for _, name in ipairs({ "Enable", "Disable", "IsEnabled" }) do + assert(sl[name] == nil, "Slider must not answer " .. name) + end + local b = CreateFrame("Button", "SlEnableControl") + for _, name in ipairs({ "Enable", "Disable", "IsEnabled" }) do + assert(type(b[name]) == "function", "Button still answers " .. name) + end + -- The Button's own predicate is the NUMBER 1 / the NUMBER 0, never a Lua boolean — its + -- false leg is 0 rather than nil, settled per body at `0x7800b0`'s `setne`+`fild` + -- (wow-re `button-enabled-state.md`; decision 2118's `binding_abi::flag` doc). + assert(b:IsEnabled() == 1, "1, not true") + b:Disable() + assert(b:IsEnabled() == 0, "0, not false and not nil") "#, ) .unwrap(); @@ -279,9 +299,9 @@ fn slider_scrollbar_wiring_does_not_recurse() { r#" local sf = CreateFrame("ScrollFrame", "SlSF") sf:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0) - sf:SetSize(100, 100) + sf:SetWidth(100); sf:SetHeight(100) local child = CreateFrame("Frame", "SlSFChild", sf) - child:SetSize(100, 300) -- 200px taller than the frame -> scroll range 200 + child:SetWidth(100); child:SetHeight(300) -- 200px taller than the frame -> scroll range 200 sf:SetScrollChild(child) bar = CreateFrame("Slider", "SlSFBar") @@ -399,7 +419,7 @@ fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() { -- Slider and not to the mouse-enabled host sitting under it. host = CreateFrame("Frame", "DdHost", nil) host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) - host:SetSize(80, 170) + host:SetWidth(80); host:SetHeight(170) host:SetFrameStrata("FULLSCREEN_DIALOG") host:EnableMouse(true) bar = CreateFrame("Slider", "DdSlider", host) diff --git a/crates/benilla-ui/src/script/tests/statusbar.rs b/crates/benilla-ui/src/script/tests/statusbar.rs index 564f8c02..28e585e3 100644 --- a/crates/benilla-ui/src/script/tests/statusbar.rs +++ b/crates/benilla-ui/src/script/tests/statusbar.rs @@ -72,7 +72,7 @@ fn statusbar_bar_region_scales_by_fraction_on_extract() { r#" local b = CreateFrame("StatusBar", "SbFill") b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) - b:SetSize(200, 20) + b:SetWidth(200); b:SetHeight(20) b:SetStatusBarTexture(0.2, 0.7, 0.2) b:SetMinMaxValues(0, 100) b:SetValue(25) @@ -137,7 +137,7 @@ fn statusbar_bar_region_crops_its_texture_rather_than_stretching_it() { r#" local b = CreateFrame("StatusBar", "SbCrop") b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0) - b:SetSize(200, 20) + b:SetWidth(200); b:SetHeight(20) b:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") b:SetMinMaxValues(0, 100) b:SetValue(25) diff --git a/crates/benilla-ui/src/script/tests/talent.rs b/crates/benilla-ui/src/script/tests/talent.rs index 6cbf1383..1f9fcc32 100644 --- a/crates/benilla-ui/src/script/tests/talent.rs +++ b/crates/benilla-ui/src/script/tests/talent.rs @@ -147,7 +147,7 @@ fn set_talent_renders_the_interleaved_tooltip() { ); s.run( r#" - local a = CreateFrame("Button", "TB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "TB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetTalent(1, 1) @@ -183,7 +183,7 @@ fn set_talent_locked_reqs_and_the_ask_once_miss() { // No spell view pushed for Ignite (11119): the render falls back, the ask is recorded. s.run( r#" - local a = CreateFrame("Button", "TB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "TB2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT2") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetTalent(1, 2) diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index 91775ba0..522e955e 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -31,7 +31,7 @@ fn line_stack_autosize_and_right_flush() { s.run( r#" local owner = CreateFrame("Button", "Slot") - owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40) + owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(owner, "ANCHOR_RIGHT") tt:AddLine("Tough Jerky", 1, 1, 1) @@ -101,7 +101,7 @@ fn empty_line_is_a_one_unit_row_and_the_chain_stays_inside_the_plate() { s.run( r#" local owner = CreateFrame("Button", "Slot2") - owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40) + owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) local tt = CreateFrame("GameTooltip", "TTE") tt:SetOwner(owner, "ANCHOR_RIGHT") tt:AddLine("Marshal McBride", 0.25, 0.75, 0.25) @@ -154,15 +154,15 @@ fn owner_clear_and_hide_lifecycle() { s.run( r#" cleared = 0 - local a = CreateFrame("Button", "A"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) - local b = CreateFrame("Button", "B"); b:SetPoint("CENTER", 50, 0); b:SetSize(10, 10) + local a = CreateFrame("Button", "A"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + local b = CreateFrame("Button", "B"); b:SetPoint("CENTER", 50, 0); b:SetWidth(10); b:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT2") tt:SetScript("OnTooltipCleared", function() cleared = cleared + 1 end) tt:SetOwner(a, "ANCHOR_RIGHT") tt:AddLine("first hover") tt:Show() assert(tt:IsOwned(a) and not tt:IsOwned(b), "owned by a") - assert(tt:GetOwner() == a, "GetOwner returns the owner wrapper") + assert(tt:BenillaGetTooltipOwner() == a, "the owner wrapper reads back") tt:SetOwner(b, "ANCHOR_LEFT") assert(cleared >= 1, "SetOwner cleared the old content") assert(tt:NumLines() == 0, "content cleared on re-own") @@ -190,7 +190,7 @@ fn settext_shows_and_both_addline_shapes() { s.set_screen_size(800.0, 600.0); s.run( r#" - local a = CreateFrame("Button", "A3"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "A3"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT3") tt:Hide() -- the XML instance ships hidden="true"; CreateFrame defaults shown tt:SetOwner(a, "ANCHOR_RIGHT") @@ -248,9 +248,9 @@ fn unmeasured_lines_hold_declared_size() { s.set_screen_size(800.0, 600.0); s.run( r#" - local a = CreateFrame("Button", "A6"); a:SetPoint("TOPLEFT", 100, -100); a:SetSize(10, 10) + local a = CreateFrame("Button", "A6"); a:SetPoint("TOPLEFT", 100, -100); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT6") - tt:SetSize(120, 32) + tt:SetWidth(120); tt:SetHeight(32) tt:SetOwner(a, "ANCHOR_RIGHT") tt:AddLine("Tough Jerky") tt:AddDoubleLine("One-Hand", "Sword") @@ -291,7 +291,7 @@ fn minimum_width_floors_autosize() { s.set_screen_size(800.0, 600.0); s.run( r#" - local a = CreateFrame("Button", "A4"); a:SetPoint("TOPLEFT", 100, -100); a:SetSize(10, 10) + local a = CreateFrame("Button", "A4"); a:SetPoint("TOPLEFT", 100, -100); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT4") tt:SetOwner(a, "ANCHOR_RIGHT") tt:AddLine("tiny") @@ -316,7 +316,7 @@ fn fadeout_ramps_then_hides() { s.run( r#" cleared = 0 - local a = CreateFrame("Button", "A5"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "A5"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT5") tt:SetScript("OnTooltipCleared", function() cleared = cleared + 1 end) tt:SetOwner(a, "ANCHOR_RIGHT") @@ -387,7 +387,7 @@ fn owner_anchored_tooltip_clamps_to_screen() { s.run( r#" local owner = CreateFrame("Button", "ZoneTextBtn") - owner:SetPoint("TOPRIGHT", -100, 0); owner:SetSize(90, 14) + owner:SetPoint("TOPRIGHT", -100, 0); owner:SetWidth(90); owner:SetHeight(14) local tt = CreateFrame("GameTooltip", "TTC") assert(tt:IsClampedToScreen(), "a GameTooltip clamps by construction") assert(not owner:IsClampedToScreen(), "a plain frame does not") @@ -539,7 +539,7 @@ fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_c s.run( r#" local owner = CreateFrame("Button", "SlotR") - owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40) + owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) local tt = CreateFrame("GameTooltip", "TTR") tt:SetOwner(owner, "ANCHOR_RIGHT") tt:AddLine("Marshal McBride") @@ -589,3 +589,92 @@ fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_c .unwrap(); assert!(s.take_errors().is_empty()); } + +/// `GameTooltip:GetAnchorType()` — ONE string, the reference's own spelling, round-tripping +/// whatever `SetOwner` was given (`0x5313e0`, table `0x854198`, argc 1, arity 1, kinds `(string)`, +/// reading `[+0x318]` back through the name table `0x531530`). +/// +/// The round trip is the whole point rather than a nicety: `_Nameplates/_Nameplates.lua:479` is +/// `if GameTooltip:GetAnchorType() ~= Anchor then GameTooltip:SetOwner(Column, Anchor) end`, so an +/// answer that never equals what the setter was handed would re-own the plate every OnUpdate; and +/// `pfUI/modules/tooltip.lua:97` compares against the literal `"ANCHOR_NONE"`. +#[test] +fn tooltip_anchor_type_round_trips_every_reachable_mode() { + let s = script(); + s.run( + r#" + AnchorTip = CreateFrame("GameTooltip", "AnchorTip") + AnchorOwner = CreateFrame("Frame", "AnchorOwner") + "#, + ) + .unwrap(); + + // Arity 1, kind string, and NO nil leg — the shapes row is `(string)` with no alternative, so + // a plate nobody has owned still answers a string. + assert_eq!( + s.eval::("return select('#', AnchorTip:GetAnchorType())") + .unwrap(), + 1, + "arity 1" + ); + assert_eq!( + s.eval::("return type(AnchorTip:GetAnchorType())") + .unwrap(), + "string", + "kind string, never nil" + ); + assert_eq!( + s.eval::("return AnchorTip:GetAnchorType()") + .unwrap(), + "ANCHOR_NONE", + "a plate nothing has owned is anchored to nothing" + ); + + // The eight modes SetOwner accepts, each answered back verbatim — ANCHOR_PRESERVE included, + // which the reference stores like any other rather than resolving back to what it preserved. + for mode in [ + "ANCHOR_RIGHT", + "ANCHOR_LEFT", + "ANCHOR_TOPRIGHT", + "ANCHOR_TOPLEFT", + "ANCHOR_BOTTOMRIGHT", + "ANCHOR_BOTTOMLEFT", + "ANCHOR_NONE", + "ANCHOR_PRESERVE", + ] { + s.run(&format!(r#"AnchorTip:SetOwner(AnchorOwner, "{mode}")"#)) + .unwrap(); + assert_eq!( + s.eval::("return AnchorTip:GetAnchorType()") + .unwrap(), + mode, + "round trip through SetOwner" + ); + } + + // Lower case in, the reference's canonical spelling out — SetOwner already folds the token. + s.run(r#"AnchorTip:SetOwner(AnchorOwner, "anchor_left")"#) + .unwrap(); + assert_eq!( + s.eval::("return AnchorTip:GetAnchorType()") + .unwrap(), + "ANCHOR_LEFT" + ); + + // An anchor SetOwner cannot honour is recorded as the ANCHOR_RIGHT it is actually PLACED by, + // never as the string it was handed — the getter reports the plate, not the request. + // `ANCHOR_CURSOR` is the live case (nine corpus files ask for it; mode 6 follows the cursor per + // frame and this engine has no such driver), and SetOwner warns rather than pretending. + s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_CURSOR")"#) + .unwrap(); + assert_eq!( + s.eval::("return AnchorTip:GetAnchorType()") + .unwrap(), + "ANCHOR_RIGHT" + ); + assert!( + s.warnings().iter().any(|w| w.contains("ANCHOR_CURSOR")), + "the unhonoured anchor is warned, not silently taken: {:?}", + s.warnings() + ); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 97ff1bd5..9e1cb0bb 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -93,7 +93,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) -- CreateFrame'd frames start SHOWN; the shipped XML instances are hidden="true". CreateFrame("GameTooltip", "GameTooltip"):Hide() CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide() @@ -101,7 +101,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() { compare_calls = {} for slot = 11, 12 do local f = CreateFrame("Button", "Doll" .. slot) - f:SetPoint("CENTER", 100, 0); f:SetSize(8, 8) + f:SetPoint("CENTER", 100, 0); f:SetWidth(8); f:SetHeight(8) f.invSlotId = slot f:RegisterEvent("SHOW_COMPARE_TOOLTIP") f:SetScript("OnEvent", function() @@ -213,7 +213,7 @@ fn set_inventory_item_renders_full_outside_compare() { ); s.run( r#" - local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") assert(tt:SetInventoryItem("player", 16) == 1, "occupied slot answers 1") @@ -266,7 +266,7 @@ fn set_hyperlink_renders_items_and_ignores_other_links() { ); s.run( r#" - local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetHyperlink("|cff1eff00|Hitem:7002:0:0:0|h[New Loop]|h|r") diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs index 0adb76d0..3e323f20 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs @@ -23,11 +23,11 @@ fn item_line_law_and_red_requirements() { }); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") - tt:SetItemById(871) - assert(tt:IsShown(), "SetItemById shows") + tt:BenillaSetItemById(871) + assert(tt:IsShown(), "BenillaSetItemById shows") "#, ) .unwrap(); @@ -95,10 +95,10 @@ fn red_lines_track_player_state() { }); s.run( r#" - local a = CreateFrame("Button", "Slot2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") - tt:SetItemById(871) + tt:BenillaSetItemById(871) "#, ) .unwrap(); @@ -122,7 +122,7 @@ fn red_lines_track_player_state() { /// The §5-verified families folded back 2026-07-10 (tooltip-content-law.md): SIGNABLE green, /// UNIQUE before STARTS_QUEST, LOCKED red, six-equal resistances collapse to the ALL line (and /// Holy never prints singly), a known taught spell reds "Already known", the description gold — -/// and NO openable line: `SetItemById` is a template source, and the whole openable/readable/ +/// and NO openable line: `BenillaSetItemById` is a template source, and the whole openable/readable/ /// creator tail rides the ref's item-OBJECT gate (`0x52e1c7`/`0x52e2e0` — byte-read 2026-07-20; /// the instance-tail law itself is in [`instance_tail_creator_and_readable`]). #[test] @@ -154,10 +154,10 @@ fn verified_families_signable_locked_resists_known() { }); s.run( r#" - local a = CreateFrame("Button", "Slot5"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot5"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") - tt:SetItemById(5518) + tt:BenillaSetItemById(5518) "#, ) .unwrap(); @@ -208,7 +208,7 @@ fn verified_families_signable_locked_resists_known() { s.run( r#" TT:SetOwner(Slot5, "ANCHOR_RIGHT") - TT:SetItemById(5519) + TT:BenillaSetItemById(5519) assert(TT:NumLines() == 1, "a lone Holy resist prints no line, got " .. TT:NumLines()) "#, ) @@ -243,10 +243,10 @@ fn proficiency_and_reputation_reds() { s.set_player_req_state(req.clone()); s.run( r#" - local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") - tt:SetItemById(871) + tt:BenillaSetItemById(871) "#, ) .unwrap(); @@ -281,7 +281,7 @@ fn proficiency_and_reputation_reds() { req2.proficiency.insert(2, 1 << 0); req2.rep_ranks.insert(72, 4); s.set_player_req_state(req2.clone()); - s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(871)"#) + s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(871)"#) .unwrap(); let lines = lines_of(&mut s); assert_eq!( @@ -300,7 +300,7 @@ fn proficiency_and_reputation_reds() { let mut alt = item; alt.proficiency_alt = Some(0); s.set_item_template(871, alt); - s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(871)"#) + s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(871)"#) .unwrap(); let lines = lines_of(&mut s); assert_eq!( @@ -328,7 +328,7 @@ fn proficiency_and_reputation_reds() { let mut req3 = req2.clone(); req3.proficiency.insert(2, 1 << 15); s.set_player_req_state(req3.clone()); - s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#) + s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(872)"#) .unwrap(); let lines = lines_of(&mut s); // `INVTYPE_WEAPONOFFHAND`, NOT `INVTYPE_SHIELD` — one enUS sentence, two keys, and only the @@ -345,7 +345,7 @@ fn proficiency_and_reputation_reds() { ); req3.can_dual_wield = true; s.set_player_req_state(req3); - s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#) + s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(872)"#) .unwrap(); let lines = lines_of(&mut s); assert_eq!( @@ -365,7 +365,7 @@ fn proficiency_and_reputation_reds() { ..Default::default() }, ); - s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(118)"#) + s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(118)"#) .unwrap(); let lines = lines_of(&mut s); assert_eq!( @@ -387,7 +387,7 @@ fn proficiency_and_reputation_reds() { ); s.run( r#" - TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(889) + TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:BenillaSetItemById(889) assert(TTTextLeft2:GetText() == "[INVTYPE_FINGER]") assert(TTTextRight2:GetText() == nil or TTTextRight2:GetText() == "", "a ring never prints its Miscellaneous type") @@ -416,13 +416,13 @@ fn required_level_one_is_hidden() { } s.run( r#" - local a = CreateFrame("Button", "SlotR"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SlotR"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") - tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(11) + tt:SetOwner(a, "ANCHOR_RIGHT"); tt:BenillaSetItemById(11) assert(tt:NumLines() == 1, "req 0: name only, got " .. tt:NumLines()) - tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(12) + tt:SetOwner(a, "ANCHOR_RIGHT"); tt:BenillaSetItemById(12) assert(tt:NumLines() == 1, "req 1 hides like req 0, got " .. tt:NumLines()) - tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(13) + tt:SetOwner(a, "ANCHOR_RIGHT"); tt:BenillaSetItemById(13) assert(tt:NumLines() == 2, "req 2 prints, got " .. tt:NumLines()) assert(TTTextLeft2:GetText() == "[MIN_LEVEL 2]") "#, @@ -515,7 +515,7 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() { ); s.run( r#" - local a = CreateFrame("Button", "SlotC"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SlotC"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetBagItem(0, 1) "#, @@ -651,7 +651,7 @@ fn instance_tail_creator_and_readable() { ); s.run( r#" - local a = CreateFrame("Button", "SlotL"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SlotL"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetBagItem(0, 1) "#, @@ -770,7 +770,7 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetBagItem(0, 1) @@ -804,7 +804,7 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() { assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]"); // Control 2: a TEMPLATE hover carries no instance at all, so nothing can override. - s.run(r#"TT:SetItemById(871)"#).unwrap(); + s.run(r#"TT:BenillaSetItemById(871)"#).unwrap(); assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]"); // Control 3: the override's other arm is ITEM_BIND_QUEST — the same text 4|5 already print. diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs index c6fe6bf9..9da0fca9 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs @@ -55,7 +55,7 @@ fn bag_item_money_law_and_fallback() { s.run( r#" money_fired = nil - local a = CreateFrame("Button", "Slot3"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot3"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetScript("OnTooltipAddMoney", function(self) money_fired = arg1 end) -- No merchant: no money handler fires. @@ -129,10 +129,10 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() { ); s.run( r#" - local a = CreateFrame("Button", "Slot4"); a:SetPoint("TOPLEFT", 10, -10); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot4"); a:SetPoint("TOPLEFT", 10, -10); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") - tt:SetItemById(7) + tt:BenillaSetItemById(7) "#, ) .unwrap(); @@ -174,7 +174,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() { s.run( r#" TT:SetOwner(Slot4, "ANCHOR_RIGHT") - TT:SetItemById(7) + TT:BenillaSetItemById(7) "#, ) .unwrap(); @@ -219,10 +219,10 @@ fn item_render_hides_the_unit_health_bar() { ); s.run( r#" - local a = CreateFrame("Button", "Slot6"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot6"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "GameTooltip"); tt:Hide() local bar = CreateFrame("StatusBar", "GameTooltipStatusBar", tt) - bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetSize(100, 8) + bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetWidth(100); bar:SetHeight(8) "#, ) .unwrap(); @@ -232,7 +232,7 @@ fn item_render_hides_the_unit_health_bar() { s.run( r#" GameTooltip:SetOwner(Slot6, "ANCHOR_RIGHT") - GameTooltip:SetItemById(9) + GameTooltip:BenillaSetItemById(9) assert(not GameTooltipStatusBar:IsShown(), "item content hides the unit bar") assert(GameTooltip:IsShown(), "the item tooltip itself shows") "#, @@ -278,7 +278,7 @@ fn real_instance_hover_renders_live_durability() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetBagItem(0, 2) @@ -289,7 +289,7 @@ fn real_instance_hover_renders_live_durability() { end assert(found == "[DURABILITY 30/40]", "live pair on a bag hover, got " .. tostring(found)) -- The template/link hover of the SAME item keeps the authored full pair. - tt:SetItemById(2264) + tt:BenillaSetItemById(2264) found = nil for i = 1, tt:NumLines() do local t = getglobal("TTTextLeft" .. i):GetText() @@ -339,7 +339,7 @@ fn broken_instance_hover_renders_zero_durability() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetBagItem(0, 1) @@ -415,7 +415,7 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetBagItem(0, 1) @@ -442,7 +442,7 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() { ); // The control: the same item as a TEMPLATE hover has no instance, so no enchant line. - s.run(r#"TT:SetItemById(22816)"#).unwrap(); + s.run(r#"TT:BenillaSetItemById(22816)"#).unwrap(); let lines = super::lines_of(&mut s); assert!( !lines.iter().any(|(t, _)| t.starts_with("Agility")), @@ -514,7 +514,7 @@ fn enchant_line_colour_is_per_slot_and_sign() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetBagItem(0, 1) @@ -597,7 +597,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetBagItem(0, 1) @@ -623,7 +623,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() { /// `ITEM_RANDOM_ENCHANT` arm is unreachable (§E1's fork). The row's name is the suffix-joined one /// (`0x5d8b00`, the same string `GetLootSlotInfo` returns), so the plate reads "… of the Monkey". /// -/// This is the reported bug's exact shape: through the template path (`SetItemById`) the same +/// This is the reported bug's exact shape: through the template path (`BenillaSetItemById`) the same /// hover printed the ITEM_RANDOM_ENCHANT placeholder until the item reached a bag. #[test] fn a_looted_roll_shows_its_lines_and_never_the_placeholder() { @@ -680,7 +680,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetLootItem(1) @@ -738,7 +738,7 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() { })); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetLootItem(1) @@ -790,7 +790,7 @@ fn a_linked_roll_shows_its_lines_and_never_the_placeholder() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") tt:SetHyperlink("|cff1eff00|Hitem:8888:0:584:0|h[Bloodrazor of the Monkey]|h|r") @@ -835,10 +835,10 @@ fn random_property_template_hover_shows_the_placeholder() { ); s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(Slot, "ANCHOR_RIGHT") - tt:SetItemById(8888) + tt:BenillaSetItemById(8888) "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs index 626c871a..9208cacd 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs @@ -60,10 +60,10 @@ fn item_set_block_counts_and_colors() { s.set_item_set(161, set_view.clone()); s.run( r#" - local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") - tt:SetItemById(6303) + tt:BenillaSetItemById(6303) -- The spacer is the reference's own literal `0x854b2c` — a space and a newline, which is -- ONE row carrying one space, not the empty string (an empty AddLine adds no line at all -- in the reference: `0x530270` bails before its `inc [esi+0x31c]`). @@ -129,7 +129,7 @@ fn item_set_block_counts_and_colors() { s.set_item_set(161, gated); s.run( r#" - TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(6303) + TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:BenillaSetItemById(6303) assert(TTTextLeft5:GetText() == "[MIN_SKILL Leatherworking 250]", "the skill line sits between header and members") assert(TTTextLeft6:GetText() == " Defias Mark") @@ -150,7 +150,7 @@ fn item_set_block_counts_and_colors() { let mut req = PlayerReqState::default(); req.skills.insert(165, 300); s.set_player_req_state(req); - s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(6303)"#) + s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:BenillaSetItemById(6303)"#) .unwrap(); let lines = lines_of(&mut s); assert_eq!( @@ -171,7 +171,7 @@ fn item_set_block_counts_and_colors() { ..Default::default() }, ); - s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(9999)"#) + s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:BenillaSetItemById(9999)"#) .unwrap(); assert_eq!(s.take_item_set_asks(), vec![1], "the set ask recorded"); assert!(s.take_errors().is_empty()); @@ -206,7 +206,7 @@ fn quest_item_hovers_render_the_rows_item_by_id() { })); s.run( r#" - local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetQuestItem("choice", 1) "#, diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 1d64889a..b95eb448 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -36,7 +36,7 @@ fn spellbook_hover_renders_the_verified_shape() { }); s.run( r#" - local a = CreateFrame("Button", "SB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetSpell(1, "spell") @@ -102,7 +102,7 @@ fn a_pet_book_hover_reads_the_pets_book_not_the_players() { }); s.run( r#" - local a = CreateFrame("Button", "SB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetSpell(1, "pet") @@ -152,7 +152,7 @@ fn tracking_hover_renders_gold_name_over_white_description() { ); s.run( r#" - local f = CreateFrame("Frame", "TRK"); f:SetPoint("CENTER", 0, 0); f:SetSize(10, 10) + local f = CreateFrame("Frame", "TRK"); f:SetPoint("CENTER", 0, 0); f:SetWidth(10); f:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(f, "ANCHOR_BOTTOMLEFT") tt:SetTrackingSpell() @@ -206,7 +206,7 @@ fn passive_omits_the_casttime_line() { }); s.run( r#" - local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetSpell(1, "spell") @@ -260,7 +260,7 @@ fn player_buff_hover_is_the_aura_variant() { -- the engine reads the string table at all, not what the strings say. SPELL_TIME_REMAINING_MIN = "<%d m>" SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>" - local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") -- 0, not 1: SetPlayerBuff takes a 1.12 CACHE POSITION (see `script::aura`'s header). @@ -329,7 +329,7 @@ fn player_buff_hover_names_the_dispel_class_in_gold() { ); s.run( r#" - local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetPlayerBuff(0) @@ -380,7 +380,7 @@ fn requirement_and_reagent_lines_render_in_law_order() { }); s.run( r#" - local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetSpell(1, "spell") @@ -458,7 +458,7 @@ fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() { ); s.run( r#" - local a = CreateFrame("Button", "TF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "TF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_BOTTOMRIGHT", 15, -25) tt:SetUnitDebuff("target", 1) @@ -535,7 +535,7 @@ fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() { ); s.run( r#" - local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") local function at(i, extra) tt:SetOwner(a, "ANCHOR_RIGHT") @@ -587,7 +587,7 @@ fn action_hover_delegates_by_kind() { ); s.run( r#" - local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetAction(24) @@ -638,7 +638,7 @@ fn action_hover_on_a_macro_slot_shows_its_name_in_white() { ); s.run( r#" - local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetAction(24) @@ -739,7 +739,7 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() { s.run( r#" - local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) CreateFrame("GameTooltip", "TT") -- Row 1 is the "Arms" header: a no-op, not a shifted hit onto a neighbouring service. @@ -855,7 +855,7 @@ fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() { s.run( r#" - local a = CreateFrame("Button", "AB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "AB2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) CreateFrame("GameTooltip", "TT") TT:SetOwner(a, "ANCHOR_RIGHT") TT:SetCraftSpell(1) @@ -914,7 +914,7 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() { SPELL_TIME_REMAINING_SEC = "<%d s>" SPELL_TIME_REMAINING_SEC_P1 = "<%d ss>" BENILLA_ANCHOR = CreateFrame("Button", "BF2") - BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10) + BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetWidth(10); BENILLA_ANCHOR:SetHeight(10) BENILLA_TIP = CreateFrame("GameTooltip", "TT2") "#, ) @@ -1003,7 +1003,7 @@ fn quest_reward_spell_getters_and_hovers() { s.set_spell_tooltip(133, fireball()); s.run( r#" - local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") "#, ) diff --git a/crates/benilla-ui/src/script/tests/tooltip_unit.rs b/crates/benilla-ui/src/script/tests/tooltip_unit.rs index 9f8fd802..b2b189aa 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -61,7 +61,7 @@ fn creature_line_law() { }); s.run( r#" - local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") assert(tt:SetUnit("target") == 1, "SetUnit returns 1 on a live unit") @@ -130,7 +130,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() { s.run( r#" UNKNOWNOBJECT = "Unknown" -- GlobalStrings.lua:4444, enUS - local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") assert(tt:SetUnit("target") == 1, "a resolved unit shows, name or no name") @@ -241,7 +241,7 @@ fn faction_line_and_civilian_gate() { ); s.run( r#" - local a = CreateFrame("Button", "UF9"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "UF9"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetUnit("target") @@ -329,7 +329,7 @@ fn level_line_variants() { ); s.run( r#" - local a = CreateFrame("Button", "UF2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local a = CreateFrame("Button", "UF2"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) local tt = CreateFrame("GameTooltip", "TT") tt:SetOwner(a, "ANCHOR_RIGHT") tt:SetUnit("target") @@ -450,7 +450,7 @@ fn world_hover_drive_and_health_watcher() { getglobal("GameTooltipTextLeft1"):SetTextColor(1, 0, 0) end) local bar = CreateFrame("StatusBar", "GameTooltipStatusBar", tt) - bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetSize(100, 8) + bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetWidth(100); bar:SetHeight(8) "#, ) .unwrap(); @@ -512,7 +512,7 @@ fn minimap_blip_tooltip_shows_and_fades() { s.run( r#" local up = CreateFrame("Frame", "UIParent") - up:SetPoint("BOTTOMLEFT"); up:SetSize(800, 600) + up:SetPoint("BOTTOMLEFT"); up:SetWidth(800); up:SetHeight(600) "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/toplevel.rs b/crates/benilla-ui/src/script/tests/toplevel.rs index db014c25..a37d9f1b 100644 --- a/crates/benilla-ui/src/script/tests/toplevel.rs +++ b/crates/benilla-ui/src/script/tests/toplevel.rs @@ -42,13 +42,13 @@ fn board_and_dialog() -> UiScript { r#" Board = CreateFrame("Frame", "Board") Board:SetPoint("BOTTOMLEFT", 100, 100) - Board:SetSize(300, 300) + Board:SetWidth(300); Board:SetHeight(300) Board:SetFrameLevel(5) Board:CreateTexture(nil, "ARTWORK"):SetTexture("Board.blp") Dialog = CreateFrame("Frame", "Dialog") Dialog:SetPoint("BOTTOMLEFT", 200, 200) -- overlaps Board's top-right quadrant - Dialog:SetSize(300, 300) + Dialog:SetWidth(300); Dialog:SetHeight(300) Dialog:CreateTexture(nil, "ARTWORK"):SetTexture("Dialog.blp") Dialog:Hide() "#, @@ -200,7 +200,7 @@ fn the_trigger_is_the_effective_visibility_transition_not_the_show_call() { r#" Holder = CreateFrame("Frame", "Holder") Holder:SetPoint("BOTTOMLEFT", 200, 200) - Holder:SetSize(300, 300) + Holder:SetWidth(300); Holder:SetHeight(300) Holder:Hide() Dialog:SetParent(Holder) Dialog:SetToplevel(true) @@ -239,7 +239,7 @@ fn a_raise_on_a_frame_that_overlaps_nothing_is_a_total_no_op() { r#" Dialog:ClearAllPoints() Dialog:SetPoint("BOTTOMLEFT", 600, 450) -- clear of Board's (100,100)-(400,400) - Dialog:SetSize(100, 100) + Dialog:SetWidth(100); Dialog:SetHeight(100) Dialog:SetToplevel(true) Dialog:Show() "#, @@ -311,20 +311,20 @@ fn a_raise_can_never_lift_a_frame_out_of_its_stratum() { r#" Med = CreateFrame("Frame", "Med") Med:SetPoint("BOTTOMLEFT", 100, 100) - Med:SetSize(300, 300) + Med:SetWidth(300); Med:SetHeight(300) Med:CreateTexture(nil, "ARTWORK"):SetTexture("Med.blp") LowOther = CreateFrame("Frame", "LowOther") LowOther:SetFrameStrata("LOW") LowOther:SetPoint("BOTTOMLEFT", 100, 100) - LowOther:SetSize(300, 300) + LowOther:SetWidth(300); LowOther:SetHeight(300) LowOther:SetFrameLevel(4) LowOther:CreateTexture(nil, "ARTWORK"):SetTexture("LowOther.blp") LowTop = CreateFrame("Frame", "LowTop") LowTop:SetFrameStrata("LOW") LowTop:SetPoint("BOTTOMLEFT", 150, 150) - LowTop:SetSize(300, 300) + LowTop:SetWidth(300); LowTop:SetHeight(300) LowTop:SetToplevel(true) LowTop:CreateTexture(nil, "ARTWORK"):SetTexture("LowTop.blp") LowTop:Hide() @@ -408,11 +408,11 @@ fn compaction_bounds_the_raise_across_repeated_shows() { r#" A = CreateFrame("Frame", "A") A:SetPoint("BOTTOMLEFT", 100, 100) - A:SetSize(300, 300) + A:SetWidth(300); A:SetHeight(300) A:SetToplevel(true) B = CreateFrame("Frame", "B") B:SetPoint("BOTTOMLEFT", 200, 200) - B:SetSize(300, 300) + B:SetWidth(300); B:SetHeight(300) B:SetToplevel(true) "#, ) @@ -451,7 +451,7 @@ fn lua_raise_acts_on_the_nearest_toplevel_ancestor_and_is_silent_without_one() { Kid = CreateFrame("Frame", "Kid", Dialog) Loose = CreateFrame("Frame", "Loose") Loose:SetPoint("BOTTOMLEFT", 200, 200) - Loose:SetSize(300, 300) + Loose:SetWidth(300); Loose:SetHeight(300) "#, ) .unwrap(); @@ -575,7 +575,7 @@ fn pressing_a_child_of_a_toplevel_window_raises_the_window() { Dialog:Show() Knob = CreateFrame("Frame", "Knob", Dialog) Knob:SetPoint("CENTER", Dialog, "CENTER") - Knob:SetSize(40, 40) + Knob:SetWidth(40); Knob:SetHeight(40) Knob:EnableMouse(true) Board:SetFrameLevel(9) Dialog:SetFrameLevel(0) @@ -643,12 +643,12 @@ fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() { -- gate is occlusion, so a frame overlapping nothing would decline whatever we press. Other = CreateFrame("Frame", "Other") Other:SetPoint("BOTTOMLEFT", 500, 100) - Other:SetSize(200, 200) + Other:SetWidth(200); Other:SetHeight(200) Other:EnableMouse(true) Other:SetToplevel(true) Tile = CreateFrame("Frame", "Tile") Tile:SetPoint("BOTTOMLEFT", 660, 260) -- clips Other's far corner only - Tile:SetSize(60, 60) + Tile:SetWidth(60); Tile:SetHeight(60) Tile:SetFrameLevel(9) Board:SetFrameLevel(9) @@ -725,7 +725,7 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() { Other:SetFrameStrata("DIALOG") Other:SetFrameLevel(1) Other:SetPoint("BOTTOMLEFT", 50, 50) - Other:SetSize(400, 400) + Other:SetWidth(400); Other:SetHeight(400) Other:EnableMouse(true) Other:SetToplevel(true) OtherKid = CreateFrame("Frame", "OtherKid", Other) -- level 2 @@ -734,13 +734,13 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() { Win:SetFrameStrata("DIALOG") Win:SetFrameLevel(1) Win:SetPoint("BOTTOMLEFT", 200, 100) - Win:SetSize(400, 400) + Win:SetWidth(400); Win:SetHeight(400) Win:EnableMouse(true) Win:SetToplevel(true) Close = CreateFrame("Button", "Close", Win) -- level 2, visible with Win Close:SetPoint("BOTTOMRIGHT", Win, "BOTTOMRIGHT", -15, 15) - Close:SetSize(100, 21) + Close:SetWidth(100); Close:SetHeight(21) Close:EnableMouse(true) Close:SetScript("OnEnter", function(self) hovered = self:GetName() end) Close:SetScript("OnClick", function(self) clicked = self:GetName() end) diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs new file mode 100644 index 00000000..e20985c3 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -0,0 +1,404 @@ +//! **The widget-method surface gate** — the sibling of [`super::reference_surface`], for the API +//! an addon reaches through a *widget* rather than through `_G` (decision 2142). +//! +//! `our_globals_stay_inside_the_1_12_surface` has guarded the global namespace since 1189, and the +//! shape gate (1842/1843/2118) guards the arity and return kinds of the methods we *have*. Between +//! them sat the question neither asks: **is the set of method NAMES on each widget class the set +//! 1.12 registers?** A method the reference has and we do not is invisible to the shape gate, +//! which skips what does not exist; a method we have and the reference does not is invisible to +//! everything, and is the superset `api-coverage.sh`'s header calls "not free" — an addon that +//! feature-detects (`if region.GetBlendMode then`) takes a path we cannot honour, and the failure +//! surfaces far from the cause. +//! +//! A corpus census over 110 top-20 and 219 vanilla addons found both directions live at once +//! (2142): `ShaguTweaks` probes `GetBlendMode`, which we were missing, and silently recolours +//! textures the reference leaves alone; `pfUI` and `_Nameplates` call `GameTooltip:GetAnchorType` +//! bare and raise. The demand is in the corpus, which is not in this repo — so this gate does not +//! measure demand. It measures the SURFACE, and every entry in the two declared lists below is +//! where the reading of the demand got written down. +//! +//! ## The reference side is a CHAIN, not 23 flat surfaces +//! +//! The reference's 23 widget registrar tables are mutually **disjoint** — a class's real surface is +//! its own table plus the tables it misses into. Comparing a live `Frame` against `0x878ec0` alone +//! reports 87 of its 88 methods as beyond-1.12, which is how you get a gate nobody believes. +//! [`WIDGET_CHAINS`] is that inheritance, and its two non-obvious rows are byte-settled, not +//! guessed: `0x87c9b8` is the base `Region` map (its 19 names are exactly [`super::super`]'s +//! `REGION_MAP_METHODS`), and `0x847ce4` is `LootButton`, whose whole surface of its own is +//! `SetSlot` (decision 1799, read off the registrar's own `mov edx,1`). +use std::collections::{BTreeMap, BTreeSet}; + +use crate::script::{widget_method_census, UiScript}; + +/// `(class, its own registrar table, the tables it inherits)`. +/// +/// `WorldFrame` is a `Frame` to Lua (1983/1984) and `TitleRegion` a bare `Region`, so neither has a +/// table of its own among the 23. `TaxiRouteFrame` is absent for the same reason (1828). +const WIDGET_CHAINS: &[(&str, &str, &[&str])] = &[ + ("Frame", FRAME, &[REGION]), + ("WorldFrame", FRAME, &[REGION]), + ("Button", BUTTON, FRAME_CHAIN), + ("LootButton", "0x847ce4", &[BUTTON, FRAME, REGION]), + ("CheckButton", "0x87bf74", &[BUTTON, FRAME, REGION]), + ("EditBox", "0x87bb68", FRAME_CHAIN), + ("StatusBar", "0x87b010", FRAME_CHAIN), + ("Slider", "0x87b260", FRAME_CHAIN), + ("ScrollFrame", "0x87b3c0", FRAME_CHAIN), + ("Model", MODEL, FRAME_CHAIN), + ("PlayerModel", PLAYER_MODEL, &[MODEL, FRAME, REGION]), + ( + "DressUpModel", + "0x84f190", + &[PLAYER_MODEL, MODEL, FRAME, REGION], + ), + ( + "TabardModel", + "0x84ee40", + &[PLAYER_MODEL, MODEL, FRAME, REGION], + ), + ("MessageFrame", "0x87b960", FRAME_CHAIN), + ("ScrollingMessageFrame", "0x87b5c0", FRAME_CHAIN), + ("ColorSelect", "0x87abb0", FRAME_CHAIN), + ("SimpleHTML", "0x87ba80", FRAME_CHAIN), + ("MovieFrame", "0x87ab4c", FRAME_CHAIN), + ("GameTooltip", "0x854198", FRAME_CHAIN), + ("Minimap", "0x84c538", FRAME_CHAIN), + ("Texture", "0x87c128", &[REGION]), + ("FontString", "0x87c1d8", &[REGION]), + // The font OBJECT (`CreateFont`) carries its own `GetName`/`GetObjectType`/`IsObjectType` and + // inherits nothing. + ("Font", "0x87c7c8", &[]), + ("TitleRegion", REGION, &[]), +]; +const REGION: &str = "0x87c9b8"; +const FRAME: &str = "0x878ec0"; +const BUTTON: &str = "0x879d00"; +const MODEL: &str = "0x878948"; +const PLAYER_MODEL: &str = "0x84f1fc"; +const FRAME_CHAIN: &[&str] = &[FRAME, REGION]; + +/// Methods the reference registers that this client does not answer — **a list that may only +/// shrink**, one row per name, naming every class it is missing on and why it has not been built. +/// +/// The corpus demand behind each is 2142's census. A name with real, live callers does not belong +/// here: it belongs implemented. +const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[ + // ── The MovieFrame trio: zero addon demand, and the stock GLUE screen calls all three ────── + // `_extracted_gluexml/MovieFrame.lua:23/30/35/47/71`. The glue screens stay ours permanently + // (0068 §8), so the intro-movie screen is owed these however little the ecosystem wants them — + // which makes this the one group here that is a real backlog item rather than a name nobody + // has ever typed. benilla has no movie playback at all, so it is a feature, not a verb. + ( + "StartMovie", + "MovieFrame", + "the intro-movie screen's own verb; benilla plays no movie yet", + ), + ("StopMovie", "MovieFrame", "the same, on the skip path"), + ( + "EnableSubtitles", + "MovieFrame", + "the same — `MovieFrame.lua:35` passes `GetMovieSubtitles()`", + ), + // ── The Minimap's four content setters ──────────────────────────────────────────────────── + // benilla draws the minimap's blips, arrow and mask from the app side (`minimap/blips`), never + // through Lua; the stock UI does not call these either. Wiring them means routing the art + // choice back through the VM, which nothing has asked for. + ( + "SetArrowModel", + "Minimap", + "app-side art; no caller in the stock UI or either corpus", + ), + ("SetBlipTexture", "Minimap", "the same"), + ("SetIconTexture", "Minimap", "the same"), + ("SetPlayerModel", "Minimap", "the same"), + // ── The Button font-object and text-colour family ───────────────────────────────────────── + // Seven getters and one setter over a button's disabled/highlight/pushed font state. benilla + // keeps that state (`button.rs`'s `Slot` triple) but publishes none of it back. **Zero call + // sites in 329 addon folders and zero in the stock UI** — the whole family is completeness. + ( + "GetDisabledFontObject", + "Button CheckButton LootButton", + "the button font-state family; no caller anywhere measured", + ), + ( + "GetDisabledTextColor", + "Button CheckButton LootButton", + "the same family", + ), + ( + "GetHighlightFontObject", + "Button CheckButton LootButton", + "the same family", + ), + ( + "GetHighlightTextColor", + "Button CheckButton LootButton", + "the same family", + ), + ( + "GetTextFontObject", + "Button CheckButton LootButton", + "the same family", + ), + ( + "GetPushedTextOffset", + "Button CheckButton LootButton", + "the same family", + ), + ( + "SetPushedTextOffset", + "Button CheckButton LootButton", + "the same family", + ), + // ── Line spacing, on the five classes that carry text ───────────────────────────────────── + // A real layout property benilla's text layout does not model — a setter here is not a store, + // it is a change to how `ui_text` lays out wrapped lines. No caller anywhere measured. + ( + "GetSpacing", + "EditBox Font FontString MessageFrame ScrollingMessageFrame", + "line spacing is not modelled by `ui_text`'s layout; no caller anywhere measured", + ), + ( + "SetSpacing", + "EditBox Font FontString MessageFrame ScrollingMessageFrame", + "the same — the setter is a layout change, not a stored value", + ), + // ── EditBox mode read-backs ─────────────────────────────────────────────────────────────── + // The four modes are all SET here (`editbox`'s `SetAutoFocus`/`SetMultiLine`/`SetNumeric`/ + // `SetPassword`) and none is readable. Cheap, and nothing has ever asked. + ( + "IsAutoFocus", + "EditBox", + "the mode is set and not readable back; no caller anywhere measured", + ), + ("IsMultiLine", "EditBox", "the same"), + ("IsNumeric", "EditBox", "the same"), + ("IsPassword", "EditBox", "the same"), + // ── ScrollingMessageFrame's line-position surface ───────────────────────────────────────── + // The scroll POSITION verbs (`ScrollUp`/`ScrollDown`/`ScrollToTop`…) are all here; these four + // report or set where that position sits, which our message buffer tracks internally and does + // not publish. No caller anywhere measured. + ( + "GetCurrentLine", + "ScrollingMessageFrame", + "the buffer's position is internal; no caller measured", + ), + ("GetCurrentScroll", "ScrollingMessageFrame", "the same"), + ("GetNumLinesDisplayed", "ScrollingMessageFrame", "the same"), + ("SetScrollFromBottom", "ScrollingMessageFrame", "the same"), + // ── The colour picker's HSV pair ────────────────────────────────────────────────────────── + // `ColorSelect` publishes the RGB pair; the HSV twin would need the conversion the reference + // does in C. The stock `ColorPickerFrame` uses RGB only, and so does every corpus caller. + ( + "GetColorHSV", + "ColorSelect", + "the RGB pair is published; nothing measured asks for HSV", + ), + ("SetColorHSV", "ColorSelect", "the same"), + // ── One texture read-back ───────────────────────────────────────────────────────────────── + ( + "IsDesaturated", + "Texture", + "`SetDesaturated` is here and not readable back; no caller measured", + ), +]; + +/// Methods this client answers that 1.12 does not register on that class — **each a deliberate, +/// reasoned exception, never a quiet superset**. Same contract as +/// [`super::reference_surface::allowed_beyond_1_12`]: a name here must say why it stays. +const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[]; + +/// The reference's widget rows, `table_va -> {name}`. +fn reference_tables() -> BTreeMap> { + let tsv = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../reference/1.12-shapes.tsv" + ); + let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv"); + let mut out: BTreeMap> = BTreeMap::new(); + for line in text.lines() { + if line.starts_with('#') || line.starts_with("name\t") { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + if f.len() < 5 || f[4] != "widget" { + continue; + } + out.entry(f[3].to_string()) + .or_default() + .insert(f[0].to_string()); + } + out +} + +/// `class -> the effective reference surface` (own table + everything it inherits). +fn reference_surface_by_class() -> BTreeMap<&'static str, BTreeSet> { + let tables = reference_tables(); + assert!( + tables.len() == 23, + "the shapes table gave {} widget registrar tables, not the reference's 23 — it changed \ + shape, and every count below is against the wrong denominator", + tables.len() + ); + let mut out = BTreeMap::new(); + for (class, own, bases) in WIDGET_CHAINS { + let mut set = tables.get(*own).cloned().unwrap_or_default(); + for base in *bases { + set.extend(tables.get(*base).cloned().unwrap_or_default()); + } + assert!(!set.is_empty(), "{class}: empty reference surface"); + out.insert(*class, set); + } + out +} + +/// `class -> what this VM answers`, asked of a live instance of each. +fn ours_by_class() -> BTreeMap> { + let script = UiScript::new().expect("VM"); + let rows = widget_method_census(&script).expect("census"); + let mut out: BTreeMap> = BTreeMap::new(); + for (class, method) in rows { + assert_ne!( + method, "!NOT-INSTANTIABLE", + "{class} could not be instantiated — the census cannot speak for a class it never made" + ); + out.entry(class).or_default().insert(method); + } + out +} + +/// A declared list, `name -> (classes, reason)`. +fn declared(list: &[(&str, &str, &str)]) -> BTreeMap> { + list.iter() + .map(|(name, classes, _)| { + ( + (*name).to_string(), + classes.split_whitespace().map(str::to_string).collect(), + ) + }) + .collect() +} + +/// Render a `class -> names` map as the declared-list rows it would need, so a failure hands you +/// the entry to paste rather than a set difference to transcribe. +fn as_rows(diff: &BTreeMap>) -> String { + let mut by_name: BTreeMap<&String, Vec<&String>> = BTreeMap::new(); + for (class, names) in diff { + for n in names { + by_name.entry(n).or_default().push(class); + } + } + by_name + .iter() + .map(|(name, classes)| { + format!( + " (\"{name}\", \"{}\", \"WHY\"),", + classes + .iter() + .map(|c| c.as_str()) + .collect::>() + .join(" ") + ) + }) + .collect::>() + .join("\n") +} + +/// Every widget method the 1.12 client registers on a class is answered by ours, or listed. +#[test] +fn every_1_12_widget_method_is_answered() { + let reference = reference_surface_by_class(); + let ours = ours_by_class(); + let listed = declared(NOT_YET_ANSWERED); + + let mut missing: BTreeMap> = BTreeMap::new(); + let mut total = 0usize; + for (class, want) in &reference { + let have = ours.get(*class).cloned().unwrap_or_default(); + total += want.len(); + let gap: BTreeSet = want + .iter() + .filter(|n| !have.contains(*n)) + .filter(|n| !listed.get(*n).is_some_and(|c| c.contains(*class))) + .cloned() + .collect(); + if !gap.is_empty() { + missing.insert((*class).to_string(), gap); + } + } + assert!( + total > 2_000, + "only {total} class/method pairs on the reference side — the chain stopped resolving" + ); + assert!( + missing.is_empty(), + "the 1.12 client registers these on a class benilla does not answer them on, and they are \ + not listed:\n{}\n\nImplement it, or add the row to NOT_YET_ANSWERED with the reason it \ + has not been built.", + as_rows(&missing) + ); + + // A listed name that came back fails, so the list cannot outlive its fix. + let mut healed: Vec = Vec::new(); + for (name, classes, _) in NOT_YET_ANSWERED { + for class in classes.split_whitespace() { + if ours.get(class).is_some_and(|h| h.contains(*name)) { + healed.push(format!("{class}:{name}")); + } + } + } + assert!( + healed.is_empty(), + "these are answered now — take them out of NOT_YET_ANSWERED so the list keeps meaning what \ + it says: {healed:?}" + ); +} + +/// Every widget method benilla answers is one 1.12 registers on that class, or a listed exception. +#[test] +fn our_widget_methods_stay_inside_the_1_12_surface() { + let reference = reference_surface_by_class(); + let ours = ours_by_class(); + let listed = declared(ALLOWED_BEYOND); + + let mut beyond: BTreeMap> = BTreeMap::new(); + for (class, have) in &ours { + let Some(want) = reference.get(class.as_str()) else { + panic!("{class} is instantiable here but has no row in WIDGET_CHAINS"); + }; + let extra: BTreeSet = have + .iter() + .filter(|n| !want.contains(*n)) + // Our host bridge and the VM's own internals declare themselves ours by prefix, the + // same discipline `reference_surface` uses for globals. + .filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_")) + .filter(|n| !listed.get(*n).is_some_and(|c| c.contains(class))) + .cloned() + .collect(); + if !extra.is_empty() { + beyond.insert(class.clone(), extra); + } + } + assert!( + beyond.is_empty(), + "benilla answers these on a class the 1.12 client does not register them on:\n{}\n\n\ + 1.12 is the target (decision 1188). Either remove it, give it its 1.12 spelling, or add \ + the row to ALLOWED_BEYOND WITH the reason it has to stay — an unexplained superset is \ + what 1189 had to roll back, and an addon that feature-detects one takes a path we cannot \ + honour.", + as_rows(&beyond) + ); + + let mut gone: Vec = Vec::new(); + for (name, classes, _) in ALLOWED_BEYOND { + for class in classes.split_whitespace() { + if !ours.get(class).is_some_and(|h| h.contains(*name)) { + gone.push(format!("{class}:{name}")); + } + } + } + assert!( + gone.is_empty(), + "these are no longer exposed — take them out of ALLOWED_BEYOND: {gone:?}" + ); +} diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 54279851..634637f9 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -96,6 +96,7 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { at = next; } } + event::fire_all_event_listeners(lua, event, &args); } impl super::UiScript { diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index bb954eb0..53b25987 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -9,7 +9,7 @@ use crate::layout::{Anchor, Point}; use crate::script::object::{frame_handle_of, frame_wrapper}; use crate::script::region::region_handle_of; use crate::script::Model; -use crate::widget::{KindState, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; +use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; use super::{ append_line, bool_arg, cancel_fade, clear_content, fire_cleared, hide_tooltip, now, @@ -25,7 +25,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // point the plate per the anchor law. The six owner anchors are the documented 1.12 API set // (spec-faithful, same posture as StatusBar's contract; the corpus exercises RIGHT/LEFT/ // BOTTOMRIGHT); ANCHOR_NONE leaves pointing to the caller (GameTooltip_SetDefaultAnchor), - // ANCHOR_PRESERVE keeps the previous placement. + // ANCHOR_PRESERVE keeps the previous placement. The mode is stored on the tooltip and read back + // by `GetAnchorType` below. + // + // **The `anchorType`-omitted default here is ANCHOR_RIGHT and the reference's is ANCHOR_NONE.** + // The reference's SetOwner core defaults `[+0x318]` to mode 7 = ANCHOR_NONE (`0x530012`, wow-re + // `bag-portrait-and-appendtext.md` §5) — no `SetPoint` at all, the plate left wherever it was. + // Two stock 1.12 files reach that leg (`QuestTimerFrame.xml:19`, `WorldStateFrame.xml:681`, + // both `GameTooltip:SetOwner(this)`), and neither is on `benilla.toc` yet, so nothing today + // takes it. It is left alone deliberately rather than quietly flipped: our ANCHOR_NONE arm + // below DROPS the plate's anchors where the reference merely skips the SetPoint, so changing + // the default would land those two hovers at the layout origin rather than "wherever it was" — + // the two questions have to be settled together, and the second one is a visible-placement + // change. Recorded here so the next reader sees a stated divergence, not an oversight. m.set( "SetOwner", lua.create_function( @@ -45,20 +57,43 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { cancel_fade(&mut model, h); tip_mut(&mut model, h)?.owner = Some(owner_h); let owner_id = model.frame_id(owner_h); - let anchor = anchor.unwrap_or_else(|| "ANCHOR_RIGHT".into()); - let pts = match anchor.to_ascii_uppercase().as_str() { - "ANCHOR_RIGHT" => Some((Point::BottomLeft, Point::TopRight)), - "ANCHOR_LEFT" => Some((Point::BottomRight, Point::TopLeft)), - "ANCHOR_TOPRIGHT" => Some((Point::BottomRight, Point::TopRight)), - "ANCHOR_TOPLEFT" => Some((Point::BottomLeft, Point::TopLeft)), - "ANCHOR_BOTTOMRIGHT" => Some((Point::TopLeft, Point::BottomRight)), - "ANCHOR_BOTTOMLEFT" => Some((Point::TopRight, Point::BottomLeft)), - "ANCHOR_NONE" | "ANCHOR_PRESERVE" => None, + // The anchor mode is resolved ONCE, into the reference's own enum, and both + // the placement below and `GetAnchorType`'s answer are taken from that one + // value — so the plate and the getter can never tell different stories. An + // unrecognised string (`ANCHOR_CURSOR` among them, which this engine cannot + // honour — see [`TooltipAnchor::Cursor`]) warns and is recorded as the + // ANCHOR_RIGHT it is actually placed by, not as the string it was handed. + let anchor = match anchor + .as_deref() + .map(str::to_ascii_uppercase) + .as_deref() + .unwrap_or("ANCHOR_RIGHT") + { + "ANCHOR_RIGHT" => TooltipAnchor::Right, + "ANCHOR_LEFT" => TooltipAnchor::Left, + "ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight, + "ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft, + "ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight, + "ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft, + "ANCHOR_NONE" => TooltipAnchor::None, + "ANCHOR_PRESERVE" => TooltipAnchor::Preserve, other => { model.record_warning(format!( "SetOwner: unknown anchor '{other}' (kept ANCHOR_RIGHT)" )); - Some((Point::BottomLeft, Point::TopRight)) + TooltipAnchor::Right + } + }; + tip_mut(&mut model, h)?.anchor = anchor; + let pts = match anchor { + TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)), + TooltipAnchor::Left => Some((Point::BottomRight, Point::TopLeft)), + TooltipAnchor::TopRight => Some((Point::BottomRight, Point::TopRight)), + TooltipAnchor::TopLeft => Some((Point::BottomLeft, Point::TopLeft)), + TooltipAnchor::BottomRight => Some((Point::TopLeft, Point::BottomRight)), + TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)), + TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => { + None } }; match pts { @@ -84,7 +119,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); } } - None if anchor.eq_ignore_ascii_case("ANCHOR_NONE") => { + None if anchor == TooltipAnchor::None => { // The caller points it (ClearAllPoints+SetPoint) — drop ours now so a // stale owner anchor never wins the frame the caller forgets to. // @@ -109,7 +144,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { model.touch_layout_retarget_frame(h, &old_targets, &[]); } } - None => {} // ANCHOR_PRESERVE + // ANCHOR_PRESERVE. `TooltipAnchor::Cursor` shares the arm for + // exhaustiveness only — the string match above never produces it (it warns + // and records ANCHOR_RIGHT instead), so nothing reaches here that way. + None => {} } } fire_cleared(lua, h); @@ -117,8 +155,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { }, )?, )?; + // GameTooltip:BenillaGetTooltipOwner() — **ours, and the prefix is what makes it honest.** + // 1.12 registers `IsOwned` (ask about ONE frame) and no getter at all, so there is no + // reference spelling for "which frame owns this plate" to be faithful to; a `GetOwner` here + // would be an unexplained superset an addon can feature-detect (1188, and the census that + // moved it, 2142). Under the `Benilla` prefix it is unreachable by accident from an addon + // that means to call a WoW function. Its one caller is the dev-only hover recorder + // (`benilla-app/src/hover_log.rs`), which needs the owner's NAME per frame so a trace reads + // the same names the director sees. m.set( - "GetOwner", + "BenillaGetTooltipOwner", lua.create_function(|lua, this: Table| { let owner = with_tip(lua, &this, |t| t.owner)?; let id = { @@ -133,6 +179,28 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { } })?, )?; + // GetAnchorType() — ONE string, the mode the last SetOwner placed this plate by + // (`0x5313e0`, table `0x854198`, argc 1, arity 1, kinds `(string)`; it reads `[+0x318]` back + // through the 9-entry name table `0x531530`). The kinds column has no nil alternative and this + // does not answer one: a tooltip nothing has owned yet answers `"ANCHOR_NONE"`, which is + // [`TooltipAnchor`]'s default and the same mode the reference's own SetOwner core falls back to. + // + // Two corpus callers, both unguarded, both raising against this VM until now, and they are the + // two shapes the verb has: `pfUI/modules/tooltip.lua:97` compares against ONE mode + // (`if GameTooltip:GetAnchorType() == "ANCHOR_NONE" then` — its OnShow reposition, so an equality + // on the exact reference spelling), and `_Nameplates/_Nameplates.lua:479` compares against a + // mode it computed (`if GameTooltip:GetAnchorType() ~= Anchor then GameTooltip:SetOwner(Column, + // Anchor) end` — a re-SetOwner suppressor, so an answer that never equals what SetOwner was + // given would re-own the plate on every OnUpdate). Both need the string to round-trip through + // the setter verbatim, which is why the mode is resolved once up there and read straight back + // here rather than re-derived from the plate's anchors. + m.set( + "GetAnchorType", + lua.create_function(|lua, this: Table| { + let anchor = with_tip(lua, &this, |t| t.anchor)?; + Ok(anchor.name()) + })?, + )?; // IsOwned(frame) — the hover re-enter loop's gate (ref ContainerFrame.lua OnUpdate): true // only while `frame` is the live owner; the owner drops on hide, so a hidden tooltip never // resurrects from a stale OnUpdate. @@ -391,7 +459,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // The item content channels (SetItemById/SetBagItem/SetMerchantItem/SetBuybackItem) live in + // The item content channels (BenillaSetItemById/SetBagItem/SetMerchantItem/SetBuybackItem) live in // their own module — the one shared renderer + the red usable law (decision 0274 P1). crate::script::tooltip_item::install_methods(lua, &m)?; // The spell/aura/action content channels (SetSpell/SetShapeshift/SetPlayerBuff/SetAction) — diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 952ad53a..03011410 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -1,7 +1,8 @@ //! The engine **item-tooltip renderer** (decision 0274 P1) — the one line law every item hover //! renders through, mirroring the real client's single shared C++ renderer (`0x52b650`, behind //! 8 of the 9 `Set*Item` bindings — wow-re `ui/scratch/tooltip-money.md`). The entry methods -//! (`SetItemById`, `SetBagItem`, `SetMerchantItem`, `SetBuybackItem`) register into the +//! (`SetBagItem`, `SetMerchantItem`, `SetBuybackItem`, and the prefixed `BenillaSetItemById` — +//! ours, because 1.12 has no id-keyed door onto the shared renderer) register into the //! GameTooltip kind table beside the widget verbs ([`super::tooltip`]). //! //! **The line law is BYTE-VERIFIED** — the 0274 §5 verdict on `0x52b650`'s emission order @@ -32,7 +33,7 @@ //! while the merchant window is open and repair mode is off — the engine computes //! `SellPrice × stack` and fires the `OnTooltipAddMoney` script; FrameXML renders the coins //! (`SetTooltipMoney`). A zero sell price in that context prints `ITEM_UNSELLABLE` -//! (`0x854a74`, pushed at `0x52e4a3`). Template sources (`SetMerchantItem`/`SetItemById`/quest +//! (`0x854a74`, pushed at `0x52e4a3`). Template sources (`SetMerchantItem`/`BenillaSetItemById`/quest //! rows) never show money, per the same law. use mlua::{Lua, MultiValue, Table, Value}; @@ -187,7 +188,7 @@ fn hyperlink_item_fields(link: &str) -> Option<(u32, u32, u32)> { (item_id != 0).then_some((item_id, enchant_id, random_property_id)) } -/// The shared id-keyed render (`SetItemById`/`SetHyperlink`): template hit → the full line law +/// The shared id-keyed render (`BenillaSetItemById`/`SetHyperlink`): template hit → the full line law /// (+ the compare arm when this is the main GameTooltip); miss → the ask + a name-only line. fn render_by_id( lua: &Lua, @@ -369,11 +370,22 @@ pub(super) fn on_shift_edge(lua: &Lua, down: bool) { /// Register the item content channels into the GameTooltip kind method table. pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { - // GameTooltip:SetItemById(itemId [, fallbackName, fallbackQuality]) — the id-keyed hover - // (quest reward rows, loot rows; a benilla extension beside the era surface). Template - // source: no money row, per the byte-verified sell-price law. + // GameTooltip:BenillaSetItemById(itemId [, fallbackName, fallbackQuality]) — the id-keyed + // hover (quest reward rows, loot rows). + // + // **Ours, and the `Benilla` prefix is what keeps it honest.** 1.12's nine `Set*Item` bindings + // all name a CONTAINER — a bag slot, a merchant row, a loot slot — because the reference's + // callers always have one; nothing there takes a bare item id. Ours do: the channels in this + // module reach [`render_by_id`] as a Rust call, but the ones that live in a sibling module + // (`tooltip_spell`'s action-bar item arm, `SetTrainerService`, `SetCraftSpell`) reach it + // through the wrapper table, and that needs a NAME. Spelling that name like a WoW function + // would be an unexplained superset an addon can feature-detect (1188; the census that moved + // it, 2142); under the prefix it is unreachable by accident from an addon that means to call + // a WoW function, and no addon has reason to call it at all. + // + // Template source: no money row, per the byte-verified sell-price law. m.set( - "SetItemById", + "BenillaSetItemById", lua.create_function( |lua, (this, item_id, fb_name, fb_q): (Table, u32, Option, Option)| { render_by_id(lua, &this, item_id, fb_name, fb_q) @@ -739,7 +751,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // supplies the instance block, p6=1), and that is the whole point of it existing here: a loot // slot is no item object, so its rolled random-suffix enchants reach the builder through the // block — never through `ITEM_FIELD_ENCHANTMENT`, which the wire does not carry for loot. - // Hovering a "… of the Monkey" drop through the template path `SetItemById` instead printed + // Hovering a "… of the Monkey" drop through the template path `BenillaSetItemById` instead printed // the `` placeholder until the item was in the bag (decision 1547). // // `slot` is the 1-based display row, like every other loot getter; the coin pile and a @@ -1281,7 +1293,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // GameTooltip:SetTradeSkillItem(skillIndex [, reagentIndex]) — the tradeskill window's item // hover (decision 0437 phase 2): with reagentIndex, that recipe's reagent's item; without, the - // recipe's PRODUCT item. Routes through the same id-keyed renderer SetItemById uses. A product + // recipe's PRODUCT item. Routes through the same id-keyed renderer BenillaSetItemById uses. A product // id of 0 (a pure-effect recipe) or an in-flight ask-once answer both fall to render_by_id's own // name-only fallback line (the recipe's name for the product channel, the reagent's own — // possibly still-nil — name for the reagent channel) rather than a no-op: that fallback already diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index 52b741b5..6687bdb9 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -439,7 +439,7 @@ pub(super) fn render_view( // "No id source" is the reference's own three-way fork (§E1): a wrapped gift, or no item // object AND no caller-supplied instance block (`+0x440 == 0`). Ours reads the same: a hover // that passes NO [`ItemInstance`] is a p6=0 leg — the template sources (merchant, quest, - // craft, buyback, send-mail, the compare legs, `SetItemById`) — plus the wrapped-gift bit. + // craft, buyback, send-mail, the compare legs, `BenillaSetItemById`) — plus the wrapped-gift bit. // // **A block-supplying source never prints the placeholder, even carrying no ids at all.** The // fork tests the block's presence, not its contents, so `SetLootItem`/`SetHyperlink`/ diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index b1e802f3..d78e8a5a 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -698,7 +698,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { ), 0x80 => { // Route through the shared item renderer (the id-keyed entry). - let f: mlua::Function = this.get("SetItemById")?; + let f: mlua::Function = this.get("BenillaSetItemById")?; f.call::<()>((this.clone(), a.action)) } 0x40 => { @@ -750,7 +750,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // `0x52b650`. No fallback name: an item id of 0 or a template still in flight // renders an EMPTY tooltip, which is the builder's own early-out. TrainerTooltip::Item(item_id) => { - let f: mlua::Function = this.get("SetItemById")?; + let f: mlua::Function = this.get("BenillaSetItemById")?; f.call::<()>((this.clone(), item_id)) } TrainerTooltip::Spell { @@ -796,7 +796,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { }; match subject { CraftTooltip::Item(item_id) => { - let f: mlua::Function = this.get("SetItemById")?; + let f: mlua::Function = this.get("BenillaSetItemById")?; f.call::<()>((this.clone(), item_id)) } CraftTooltip::Spell(spell_id) => { diff --git a/crates/benilla-ui/src/script/tutorial.rs b/crates/benilla-ui/src/script/tutorial.rs index 0e2a6c47..9f881e1c 100644 --- a/crates/benilla-ui/src/script/tutorial.rs +++ b/crates/benilla-ui/src/script/tutorial.rs @@ -15,7 +15,7 @@ use mlua::{Lua, Value}; -use super::binding_abi::{number_arg, predicate}; +use super::binding_abi::{flag, number_arg}; use super::Model; /// `FlagTutorial`'s clamp: `0 ≤ n − 1 < 0x32` — the fifty ids `GlobalStrings.lua` names. @@ -57,7 +57,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .tutorial_bank .as_ref() .is_some_and(|b| b.iter().any(|&byte| byte != 0xFF)); - Ok(predicate(enabled)) + Ok(flag(enabled)) })?, )?; diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index e90f1dd3..170ed709 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -167,10 +167,15 @@ pub enum QuadContent { /// only carries the token; resolving it to a rendered image is the app's job (the modern /// 2D portrait is a high-res model bake). portrait_unit: Option, - /// On-screen rotation about the quad center, radians, **counterclockwise-positive** (the - /// `SetRotation` texture API — a later-era method benilla ships early: the world-map - /// player arrow spins by it, standing in for the reference's engine arrow model; 0203 - /// flags the stand-in). `0.0` for the overwhelmingly common unrotated case. + /// On-screen rotation about the quad center, radians, **counterclockwise-positive**. + /// + /// **Always `0.0` now — nothing writes it any more.** It carried a later-era + /// `Texture:SetRotation` benilla shipped early so the world-map player arrow could spin, + /// standing in for the reference's engine arrow model (0203 flags the stand-in). That arrow + /// became a real Model frame driven by `ModelState::facing` (`script::worldmap_arrow`), and + /// the verb has since come off the Texture map — 1.12 registers `SetRotation` on PlayerModel + /// alone (`0x84f1fc`). The plumbing is left standing rather than pruned in the same change, + /// because it runs into the app's quad emit; see [`RegionData::rotation`]. rotation: f32, /// `Texture:SetDesaturated(1)` — draw the sampled texel as its **luminance** instead of /// its colour (decision 1327). The renderer greys the texel and *then* modulates by @@ -279,6 +284,67 @@ pub enum JustifyV { Bottom, } +/// A Texture's blend mode — the client's shared `alphaMode` enum, the `{value, name}` table at +/// `0x811aa8`: `DISABLE=0 · ALPHAKEY=1 · BLEND=2 · ADD=3 · MOD=4` (wow-re +/// `system/ui/ui.md:1383`, `scratch/rf28-typed-widget-loadxml.md:22`). It is one enum reached two +/// ways — the XML `alphaMode=` attribute and `Texture:SetBlendMode` — and read back by +/// `Texture:GetBlendMode`. +/// +/// **`Blend` is the default because the CSimpleTexture ctor writes it**: `0x76fc64 mov +/// [esi+0xd0],2`, which `ActionButtonTemplate.xml`'s `` keeps by declaring no +/// `alphaMode` (wow-re `scratch/button-state-texture-path-setter.md`, where the value is +/// load-bearing for a different question and therefore evidence rather than assertion — mode 1 +/// would alpha-test a half-alpha vertex colour away entirely, mode 2 ghosts it). It is the answer +/// every ordinary texture gives `ShaguTweaks/mods/dark-ui-elements.lua:169`'s +/// `region:GetBlendMode() == "ADD"` guard, so getting it wrong is the same recolouring bug one +/// branch over. +/// +/// **Only ADD changes what this engine draws.** The renderer takes the ADD/not-ADD bit +/// ([`QuadContent::Texture::additive`]); `DISABLE`, `ALPHAKEY` and `MOD` are carried faithfully +/// through the setter and the getter and drawn as straight alpha — the stated v1 gap, unchanged by +/// storing the whole mode instead of a bool. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BlendMode { + /// `DISABLE` = 0 — `GL_ONE, GL_ZERO`, alpha reference 0. Drawn as straight alpha here. + Disable, + /// `ALPHAKEY` = 1 — `GL_ONE, GL_ZERO` with alpha reference 224/255. Drawn as straight alpha here. + AlphaKey, + /// `BLEND` = 2 — `GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA`. The ctor's value, hence the default. + #[default] + Blend, + /// `ADD` = 3 — `GL_SRC_ALPHA, GL_ONE`. The one mode the renderer acts on. + Add, + /// `MOD` = 4 — `GL_DST_COLOR, GL_ZERO`. Drawn as straight alpha here. + Mod, +} + +impl BlendMode { + /// Parse the enum's own spelling, as `SetBlendMode` and the XML `alphaMode=` attribute take it. + /// Case-insensitive (the Lua binding coerces through the shared string-arg law); `None` for a + /// name outside the table's five. + pub fn parse(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "DISABLE" => Some(BlendMode::Disable), + "ALPHAKEY" => Some(BlendMode::AlphaKey), + "BLEND" => Some(BlendMode::Blend), + "ADD" => Some(BlendMode::Add), + "MOD" => Some(BlendMode::Mod), + _ => None, + } + } + + /// The reference's own spelling, as `GetBlendMode()` answers it (the name column of `0x811aa8`). + pub const fn name(self) -> &'static str { + match self { + BlendMode::Disable => "DISABLE", + BlendMode::AlphaKey => "ALPHAKEY", + BlendMode::Blend => "BLEND", + BlendMode::Add => "ADD", + BlendMode::Mod => "MOD", + } + } +} + /// A `FontString`'s glyph outline (the XML `outline`/`` attr, OUTLINETYPE): `NONE` /// (the common case), `NORMAL`, or `THICK`. Resolved through the font registry and readable via /// `GetFont`; the v1 text renderer does **not** yet rasterize outlines (a documented gap — the atlas @@ -510,9 +576,17 @@ pub(crate) struct RegionData { /// opaque, the next `length` ramp 1→0, the rest invisible. `None` = no gradient (every /// FontString but an armed quest description). Cleared by `SetText` — fresh text draws whole. pub(crate) alpha_gradient: Option<(f32, f32)>, - /// WoW `ADD` blend (`SetBlendMode("ADD")` / XML `alphaMode` — the shared enum `0x811aa8`). - /// Highlight state textures default to it (the client's `SetHighlightTexture` contract). - pub(crate) additive: bool, + /// The region's blend mode (`SetBlendMode` / XML `alphaMode` — the reference's `[texture+0xd0]`, + /// the shared enum `0x811aa8`). Highlight state textures are created at + /// [`BlendMode::Add`] (the client's `SetHighlightTexture` contract); everything else starts at + /// the ctor's [`BlendMode::Blend`]. + /// + /// **The whole mode is kept, not just "is it ADD"**, because `GetBlendMode` answers it back as + /// a string and a getter that could only ever say `"BLEND"` or `"ADD"` would answer `"BLEND"` + /// to a texture the caller set to `"MOD"`. The renderer still reads only the ADD/not-ADD + /// distinction ([`QuadContent::Texture::additive`]) — that is unchanged, and the three modes it + /// flattens are the stated gap they always were. + pub(crate) blend: BlendMode, /// Explicit region size (`SetWidth`/`SetHeight`/XML ``); `None` = derive. Size fills the /// axis the anchors don't pin (the client's "0 = derive") — so under a texture's implicit /// SetAllPoints (two corners pin everything) an authored size is structurally unread, which is @@ -550,8 +624,24 @@ pub(crate) struct RegionData { /// The ``/`SetTexCoord` UV mapping ([`TexCoords`]: the 4-edge crop, or the 8-arg /// affine quad). `None` = the full texture. Slices the quadrant/atlas art (decision 0084). pub(crate) tex_coords: Option, - /// On-screen rotation about the region center (`SetRotation`, radians, counterclockwise- - /// positive) — see [`QuadContent::Texture::rotation`]. + /// `Texture:SetTexCoordModifiesRect(flag)` / `GetTexCoordModifiesRect()` — the reference's + /// `[texture+0x124]`, whose sole writer is `SetTexCoordModifiesRect 0x79c080` + /// (`0x79c113 mov [edi+0x124],eax`) and which is BSS-zero otherwise (wow-re + /// `scratch/taxiroute-widget-type.md`, `ui.md:5594`). + /// + /// **State only, and the geometry half is NOT wired.** In the reference the flag gates a + /// rect-recompute leg (`ui.md:4619`, `0x770462`): with it set, a `SetTexCoord` re-derives the + /// region's own rect from the UV quad rather than leaving the rect alone and merely resampling + /// inside it. Honouring that here means `region::layout`'s resolve reading this flag and + /// deriving the region's rect from [`Self::tex_coords`] instead of from its anchors and size — + /// a resolve-order change, not a paint one, and nothing in either addon corpus or the stock UI + /// calls the SETTER, so there is no measured case to build it against. Stored and answered + /// truthfully, unread by layout; stated here rather than left to be discovered. + pub(crate) tex_coord_modifies_rect: bool, + /// On-screen rotation about the region center, radians, counterclockwise-positive. + /// **Unwritable: the `Texture:SetRotation` that was its only setter is gone** (1.12 registers + /// the name on PlayerModel alone) — see [`QuadContent::Texture::rotation`] for why it is still + /// here. pub(crate) rotation: f32, /// The named font object this FontString last resolved (`inherits=`/`SetFontObject`) — our /// `FONTINSTANCE+0x028 parentFontObject`, and a **live** link: mutating that object diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 9b7991df..5cc47a80 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -3,11 +3,11 @@ //! through the parent's `with_unit`/`pick_unit_token` helpers. //! //! **Every predicate here returns through one function** — [`super::unit_predicate`] when it reads -//! a snapshot field, [`predicate`] when it computes its own bool. Neither ever hands mlua a Rust -//! `bool`: all 29 of the reference's unit predicates push the constant double `1.0` -//! (`lua_pushnumber 0x6f3810`) or `nil` (`lua_pushnil 0x6f37f0`), exactly one value at every live -//! `ret`, and **no binding in the 83-entry table at `0x850438` calls `lua_pushboolean 0x6f39f0` at -//! all** (decisions 1830, 2043, 2048). A new predicate that open-codes +//! a snapshot field, [`flag`](super::super::binding_abi::flag) when it computes its own bool. +//! Neither ever hands mlua a Rust `bool`: all 29 of the reference's unit predicates push the +//! constant double `1.0` (`lua_pushnumber 0x6f3810`) or `nil` (`lua_pushnil 0x6f37f0`), one value +//! at every live `ret`, and **no binding in the 83-entry table at `0x850438` calls +//! `lua_pushboolean 0x6f39f0` at all** (decisions 1830, 2043, 2048). A new predicate that open-codes //! `Value::Integer(1)`/`Value::Nil`, or returns a `bool`, is the drift those records exist to stop. //! //! **The scope of that claim is the unit table, not "the binding surface"** — 2043 said the wider @@ -17,7 +17,7 @@ use mlua::{Lua, Value}; -use super::super::binding_abi::predicate; +use super::super::binding_abi::flag; use super::super::Model; use super::{ check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token, @@ -112,7 +112,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?; // One value, and it is the NUMBER 1 or nil — never a boolean, the same shape the // rest of this family answers in. - Ok(predicate(hit)) + Ok(flag(hit)) })?, )?; } @@ -154,7 +154,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { .as_ref() .and_then(|t| model.unit(t)) .map_or(0, |u| u.guid); - Ok(predicate(by_flag || guid == model.party.leader_guid)) + Ok(flag(by_flag || guid == model.party.leader_guid)) })?, )?; @@ -417,7 +417,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { )?); let token = pick_unit_token(&a, &b); let r = with_unit(lua, &token, 0u8, |u| u.reaction)?; - Ok(predicate((1..=2).contains(&r))) + Ok(flag((1..=2).contains(&r))) })?, )?; g.set( @@ -437,7 +437,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { )?); let token = pick_unit_token(&a, &b); let r = with_unit(lua, &token, 0u8, |u| u.reaction)?; - Ok(predicate(r >= 5)) + Ok(flag(r >= 5)) })?, )?; @@ -467,7 +467,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { )?); let model = lua.app_data_ref::().expect("model app_data"); let player_level = model.player_req.level; - Ok(predicate( + Ok(flag( unit.and_then(|u| model.unit(&u)) .is_some_and(|u| super::is_civilian_kill(u, player_level)), )) @@ -482,7 +482,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "UnitPlayerControlled", lua.create_function(|lua, unit: Option| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate( + Ok(flag( unit.and_then(|u| model.unit(&u)) .is_some_and(|u| u.player_controlled), )) @@ -525,7 +525,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { if !ua.exists || !ub.exists { return Ok(Value::Nil); } - Ok(predicate(a == b || (ua.guid != 0 && ua.guid == ub.guid))) + Ok(flag(a == b || (ua.guid != 0 && ua.guid == ub.guid))) })?, )?; @@ -549,7 +549,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "Usage: UnitAffectingCombat(\"unit\")", )?; let hot = with_unit(lua, &Some(token), false, |u| u.exists && u.in_combat)?; - Ok(predicate(hot)) + Ok(flag(hot)) })?, )?; @@ -578,7 +578,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "HasFullControl", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.player_control)) + Ok(flag(model.player_control)) })?, )?; @@ -617,7 +617,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { !model.party.members.is_empty() }; let hit = grouped && (in_group(u.guid) || in_group(u.owner)); - Ok(predicate(hit)) + Ok(flag(hit)) })?, )?; } @@ -638,7 +638,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { .iter() .any(|m| m.guid != 0 && m.guid == u.guid) }); - Ok(predicate(hit)) + Ok(flag(hit)) })?, )?; @@ -668,7 +668,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { .members .iter() .any(|m| m.guid != 0 && m.guid == u.guid))); - Ok(predicate(hit)) + Ok(flag(hit)) })?, )?; @@ -695,7 +695,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let ok = with_unit(lua, &token, false, |u| { u.exists && u.is_player && u.reaction >= 5 })?; - Ok(predicate(ok)) + Ok(flag(ok)) })?, )?; @@ -895,7 +895,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { r#"Usage: UnitHasRelicSlot("unit")"#, )?); let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?; - Ok(predicate(has)) + Ok(flag(has)) })?, )?; @@ -1210,7 +1210,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "IsResting", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.resting)) + Ok(flag(model.resting)) })?, )?; // PartialPlayTime() / NoPlayTime() → 1/nil: the two anti-addiction play-time regimes, read @@ -1223,14 +1223,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "PartialPlayTime", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.partial_play_time)) + Ok(flag(model.partial_play_time)) })?, )?; g.set( "NoPlayTime", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(predicate(model.no_play_time)) + Ok(flag(model.no_play_time)) })?, )?; // GetBillingTimeRested() → the account's rested billing MINUTES, as one number, always @@ -1456,7 +1456,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { if had { model.target_clear = true; } - Ok(predicate(had)) + Ok(flag(had)) })?, )?; diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index fef8fc8b..c5d21272 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -16,7 +16,7 @@ //! //! **Every predicate in this family answers the number `1` or `nil`, never a Lua boolean**, and it //! answers it through the one helper — [`unit_predicate`], over -//! [`super::binding_abi::predicate`]. Decision 2043; the law is 1830's, which settled the same +//! [`super::binding_abi::flag`]. Decision 2043; the law is 1830's, which settled the same //! question for the widget predicates a family earlier. //! //! v1 gaps, stated not hidden: the snapshot carries only the *active* power slot (see the @@ -217,6 +217,24 @@ pub struct UnitState { /// and `0x1000`/`0x2000` (the play-time regimes) all move without touching either. Firing off /// the decoded pair would silently under-announce every one of them. pub player_flags: u32, + /// `UNIT_DYNAMIC_FLAGS` (descriptor index 143), raw — what **`UNIT_DYNAMIC_FLAGS`** the + /// *event* fires on, the [`Self::flags`] pattern a hundred fields over. + /// + /// The event is id **137**, and the id is not arbitrary: the reference's generic bridge + /// `0x51bbb0` registers one watch per unit-window field whose name-table slot is non-NULL, and + /// the id it dispatches is that field's own window index — `137 + 6 = 143`, this field. + /// Byte-verified here rather than taken from the note: `0xbe1198 + 137*4 = 0xbe13bc` has + /// exactly one writer image-wide (`0x51ad8b`), and the pointer it stores resolves to + /// `"UNIT_DYNAMIC_FLAGS"`. The watch **length** is 4 — selector byte `[0x51bc98 + 137] = 4`, + /// jump-table entry `0x51bbf9 mov eax,4` — so the gate is a `repe cmpsb` over exactly this one + /// dword against the object's shadow copy (`0x4655bb`), and **any** bit moving fires it. + /// + /// Raw for the same reason [`Self::player_flags`] is, and the cost of getting it wrong is + /// larger here: this struct decodes four of its bits ([`Self::tapped`], + /// [`Self::tapped_by_player`], and — through the store — lootable `0x1` and dead-looking + /// `0x20`), while `0x2` (tracked, Hunter's Mark) and `0x10` moves without touching any of + /// them. Firing off the decoded subset would silently under-announce those. + pub dynamic_flags: u32, /// The unit's owner — `UNIT_FIELD_SUMMONEDBY`, else its charmer, else its creator; `0` for /// nobody's. What `UnitPlayerOrPetInParty`/`InRaid` read for the "or pet" half (1958). pub owner: u64, @@ -757,7 +775,7 @@ fn with_unit( /// Rust `bool`, and mlua pushes tag 1. That is the one shape 1.12 cannot produce: `UnitExists` /// (`0x515fb0`) pushes `lua_pushnumber` (`0x6f3810`, tag 3, the double `1.0`) or `lua_pushnil` /// (`0x6f37f0`, tag 0) and nothing else, and so does every other predicate in the family. So the -/// whole family goes through [`super::binding_abi::predicate`] — 1830's widget law, which is the +/// whole family goes through [`super::binding_abi::flag`] — 1830's widget law, which is the /// *same* law, finally applied to the unit surface (decision 2043). A predicate that hand-computes /// its own bool calls that helper directly; one that reads a snapshot field calls this. Both end at /// the one push site, which is what stops the family drifting apart again. @@ -770,9 +788,7 @@ fn unit_predicate( token: &Option, f: impl FnOnce(&UnitState) -> bool, ) -> mlua::Result { - Ok(super::binding_abi::predicate(with_unit( - lua, token, false, f, - )?)) + Ok(super::binding_abi::flag(with_unit(lua, token, false, f)?)) } /// The `Unit*`/`GetQuestGreenRange` Lua binding registrations — split from this module's diff --git a/crates/benilla-ui/src/script/worldmap_arrow.rs b/crates/benilla-ui/src/script/worldmap_arrow.rs index fb584a4b..a2351706 100644 --- a/crates/benilla-ui/src/script/worldmap_arrow.rs +++ b/crates/benilla-ui/src/script/worldmap_arrow.rs @@ -361,9 +361,9 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); s.run( - r#"WM = CreateFrame("Frame", "WM") WM:SetSize(1024, 768) WM:SetPoint("CENTER") + r#"WM = CreateFrame("Frame", "WM") WM:SetWidth(1024); WM:SetHeight(768) WM:SetPoint("CENTER") Detail = CreateFrame("Frame", "WorldMapDetailFrame", WM) - Detail:SetSize(1002, 668) Detail:SetPoint("CENTER")"#, + Detail:SetWidth(1002); Detail:SetHeight(668) Detail:SetPoint("CENTER")"#, ) .unwrap(); s diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index c3877add..db3092bd 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -259,6 +259,67 @@ impl KindState { } } +/// A tooltip's anchor mode — the reference's `+0x318`, which `SetOwner` writes and +/// `GetAnchorType 0x5313e0` reads back through the 9-entry `{value, name}` table `0x531530` +/// (wow-re `bag-portrait-and-appendtext.md` §5; the mode ids in +/// `hover-hide-and-tooltip-owner-law.md` §4). +/// +/// **All nine of the reference's modes are named here even though `SetOwner` cannot reach +/// [`TooltipAnchor::Cursor`].** The variant is not decoration: mode 6 is the cursor-following +/// tooltip, driven per frame by the class's own OnUpdate override `0x530b20` (re-anchor to +/// `[root+0x1118/0x111c]`), and nine corpus files ask for it by name — `pfUI`'s tooltip, xpbar and +/// chat modules, `pfQuest/browser.lua`, `TipBuddy`. Following the cursor is a mechanism this engine +/// does not have yet, and 1203 says a verb is not stubbed to make a caller happy, so +/// `tooltip::verbs`'s `SetOwner` still warns on the string and keeps its previous placement rather +/// than silently pretending. Naming the variant is what keeps that gap visible instead of implicit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TooltipAnchor { + /// `ANCHOR_LEFT` — the plate's right edge on the owner's left. + Left, + /// `ANCHOR_RIGHT` — the plate's left edge on the owner's right; the stock UI's overwhelming + /// default (87 of the 123 `ANCHOR_*` mentions in 1.12's FrameXML). + Right, + /// `ANCHOR_BOTTOMLEFT`. + BottomLeft, + /// `ANCHOR_BOTTOMRIGHT`. + BottomRight, + /// `ANCHOR_TOPLEFT`. + TopLeft, + /// `ANCHOR_TOPRIGHT`. + TopRight, + /// `ANCHOR_CURSOR` — mode 6, the plate re-anchored to the live cursor every frame. Reachable + /// in the reference and NOT constructible here; see the type doc. + Cursor, + /// `ANCHOR_NONE` — mode 7: the owner is recorded and no `SetPoint` is applied, leaving the + /// caller to point the plate (`GameTooltip_SetDefaultAnchor` does exactly that on the next + /// line). **The default**, because it is the mode the reference's own `SetOwner` core stores + /// when the caller passes no anchor string (`0x530012`), and the only one of the nine that is + /// true of a plate nothing has anchored yet. + #[default] + None, + /// `ANCHOR_PRESERVE` — mode 8: the owner is recorded and the previous placement is kept + /// (`0x52fe90` skips the anchor-apply). Stored like any other mode, so `GetAnchorType` answers + /// `"ANCHOR_PRESERVE"` after one — the reference does not resolve it back to what it preserved. + Preserve, +} + +impl TooltipAnchor { + /// The reference's own spelling, as `GetAnchorType()` answers it (table `0x531530`). + pub const fn name(self) -> &'static str { + match self { + TooltipAnchor::Left => "ANCHOR_LEFT", + TooltipAnchor::Right => "ANCHOR_RIGHT", + TooltipAnchor::BottomLeft => "ANCHOR_BOTTOMLEFT", + TooltipAnchor::BottomRight => "ANCHOR_BOTTOMRIGHT", + TooltipAnchor::TopLeft => "ANCHOR_TOPLEFT", + TooltipAnchor::TopRight => "ANCHOR_TOPRIGHT", + TooltipAnchor::Cursor => "ANCHOR_CURSOR", + TooltipAnchor::None => "ANCHOR_NONE", + TooltipAnchor::Preserve => "ANCHOR_PRESERVE", + } + } +} + /// A `GameTooltip`'s runtime state (decision 0274). The line *text/color/wrap* is not duplicated /// here — each line pair is a real named FontString region (`TextLeftN`/`TextRightN`, /// engine-created on demand, published as Lua globals exactly like the real template's 30 @@ -282,6 +343,10 @@ pub struct TooltipState { /// `SetOwner`'s frame — dropped on hide (`IsOwned` is the hover re-enter loop's gate, /// ref `ContainerFrame.lua` OnUpdate). pub owner: Option, + /// The anchor mode the last `SetOwner` placed this plate by, read back by `GetAnchorType()` — + /// the reference's `+0x318` (wow-re `bag-portrait-and-appendtext.md` §5, + /// `hover-hide-and-tooltip-owner-law.md` §4). + pub anchor: TooltipAnchor, /// `SetMinimumWidth(w)` — a floor on the auto-sized width (the ref's money-row floor). /// Cleared (0.0) by `ClearLines`/hide, like the content. pub min_width: f32, @@ -407,7 +472,7 @@ pub struct ModelState { /// written by the engine because the pane authored no size (decision 2015). The geometry /// getters `0x76d080`/`0x76d0d0` answer it whenever no size is authored; here it is written /// into the layout input when the file's facts land and re-derived when the screen's aspect - /// moves, and an authored `SetWidth`/`SetHeight`/`SetSize` clears it for good. + /// moves, and an authored `SetWidth`/`SetHeight` clears it for good. pub implicit_size: bool, /// The pane's yaw in radians — `CSimpleModel+0x39c`. **One slot, written by two verbs on two /// different classes**: `Model:SetFacing` (`0x76dce0`) and `PlayerModel:SetRotation` @@ -961,15 +1026,29 @@ pub const MINIMAP_DEFAULT_PLAYER_MODEL: &str = "Interface\\Minimap\\MinimapArrow /// A `CSimpleScrollFrame`'s runtime state: the frame whose anchors are overridden to track the /// scroll offset ([`crate::script::UiScript::resolve`]'s scroll-child override), and the current -/// vertical scroll position. `SetVerticalScroll` stores the offset VERBATIM — the reference's -/// `0x786db0` never reads the range (decision 2017) — and the range is always computed live from -/// the resolved rects (never cached here), so this struct carries only the two members the -/// client's `SetScrollChild`/`SetVerticalScroll` actually set (`[+0x318]`, `[+0x328]`). +/// scroll position on each axis. `SetVerticalScroll` stores the offset VERBATIM — the reference's +/// `0x786db0` never reads the range (decision 2017) — and the ranges are always computed live from +/// the resolved rects (never cached here), so this struct carries only the three members the +/// client's `SetScrollChild`/`SetHorizontalScroll`/`SetVerticalScroll` actually set (`[+0x318]`, +/// `[+0x324]`, `[+0x328]`). #[derive(Clone, Debug, Default, PartialEq)] pub struct ScrollFrameState { /// The scroll child (`SetScrollChild`) — the one frame whose content pans within this frame's /// rect. `None` = no child (nothing to clip or offset). pub child: Option, + /// The horizontal scroll offset in px (`SetHorizontalScroll`), unclamped — the reference's + /// `[+0x324]`, whose setter `0x786d30` is the byte-clone of the vertical one's `0x786db0`. + /// + /// **The sign is the mirror of the vertical one below, and that is the reference's, not a + /// slip.** `0x787100` re-anchors the child as + /// `SetPoint(TOPLEFT, self, TOPLEFT, +[+0x324], +[+0x328])` — both offsets handed over raw, + /// neither negated (wow-re `scrollframe-offset-and-range-law.md` §2.3/§6.2). x grows right, so + /// a POSITIVE horizontal offset pushes the child right and reveals nothing new; scrolling + /// right takes a NEGATIVE one, where scrolling down takes a positive vertical. + /// `aux-addon/tabs/search/filter.lua:315-322` corroborates it from the caller's side: it + /// bounds x into `[min(0, frameWidth - contentWidth - 10), 0]` and y into + /// `[0, contentHeight - frameHeight]`. + pub horizontal: f32, /// The vertical scroll offset in px (`SetVerticalScroll`), unclamped — the reference's /// `[+0x328]`. XML y-positive-up: a positive offset lifts the child /// (`child.top = scrollframe.top + vertical`), bringing content below the fold into view. The @@ -1480,9 +1559,6 @@ pub struct SliderState { /// `true` = VERTICAL (the ctor default; value maps along the track's height, min at the top), /// `false` = HORIZONTAL (`orientation`; shared enum `0x811b00` HORIZONTAL=0/VERTICAL=1). pub vertical: bool, - /// `Enable`/`Disable` (`IsEnabled`). A disabled slider does not respond to thumb drag; the ctor - /// enables it (the interactive-widget ctors take mouse — Button/EditBox/ScrollFrame do too). - pub enabled: bool, /// The thumb texture region (`SetThumbTexture`/``), created on first set. A /// renderer positions this region's rect at [`Self::fraction`] along the orientation axis. pub thumb: Option, @@ -1498,7 +1574,6 @@ impl Default for SliderState { has_value: false, step: 0.0, vertical: true, - enabled: true, thumb: None, } } diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 71c7e17f..f4c08727 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -177,7 +177,7 @@ pub use kinds::{ ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState, - ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipState, + ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipAnchor, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,