diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 7239435f..8250c900 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -133,7 +133,10 @@ Interface\FrameXML\WorldFrame.xml # sixty-six globally-routed events and their arms. It sources LocaleProperties.lua itself. Its # OnUpdate also ticks FCF_OnUpdate, UnitPopup_OnUpdate, BattlefieldFrame_OnUpdate, the # battlefield-position ask (1980) and the ready-check timeout (1989) — call-time names, all of -# them below. LowHealthFrame (parent WorldFrame, above) and OutOfControlFrame ride along. +# them below. LowHealthFrame (parent WorldFrame, above) and OutOfControlFrame ride along — +# created hidden and never shown: the one function that would flash them, PlayerStatus_OnUpdate, +# has no caller in the image or the chain (wow-re, decision 1999). Dead code in 5875, kept as +# the reference keeps it. # What stayed ours moved out: the window tab template and its settle + the placeholder icon # (ScrollTemplates.xml), 0561's bag verbs (ContainerFrameAdapters.xml), the script log's row # (ScriptLogFrame.xml), and three aliases in OptionsFrame.xml that answer the reference's three diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 876fdd96..9f6aae9d 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -642,8 +642,26 @@ fn drive_live_fps( ) }) .unwrap_or_default(); + let tiles = seen + .tiles + .map(|(f, w)| format!(" tiles={f}/{w}")) + .unwrap_or_default(); + if !seen.orphans.is_empty() { + let top: Vec = seen + .orphans + .iter() + .take(8) + .map(|(id, label, n)| format!("#{id} {label} x{n}")) + .collect(); + info!( + "FPS_PROBE orphan placed parts: {} across {} placements — {}", + seen.orphan_parts, + seen.orphans.len(), + top.join("; ") + ); + } let residency_line = format!( - " mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}{vis}", + " mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}{vis}{tiles} orphan_parts={orphans}", seen.mats, seen.mats_parked, seen.meshes, @@ -680,6 +698,7 @@ fn drive_live_fps( n[0], n[1], n[2], n[3] ) }, + orphans = seen.orphan_parts, ); println!( "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{cam_pose}{gate}{sky}{ribbons}{culled}", diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index ff58457b..d84f7f18 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -109,6 +109,18 @@ type EntityLightReadout = ( pub(super) struct InspectStores<'w, 's> { stores: Query<'w, 's, &'static ObjectStore>, kinds: Query<'w, 's, &'static crate::net::NetEntity>, + /// Every pickable part with its draw verdict, for the card's `parts alive` line: a prop + /// spawned twice reads twice its model's batch count here, and nowhere else. + objects: Query< + 'w, + 's, + ( + &'static benilla_world::interact::WorldObject, + &'static bevy::camera::visibility::ViewVisibility, + &'static GlobalTransform, + Has, + ), + >, collision: Query<'w, 's, GoCollisionReadout>, lit: Query<'w, 's, EntityLightReadout>, motion: Query<'w, 's, MotionReadout>, @@ -232,6 +244,33 @@ pub(super) fn inspect_ui( .entity .and_then(|e| stores.tags.get(e).ok()) .map(|t| format!("tag {}", benilla_world::mesh_tag::describe(t.0))); + // The duplicate readout: every live part naming this same object, and how many of them + // drew this frame. A doodad has one part per render batch; a doubled placement shows twice + // that here — the census the FPS probe prints as `orphan_parts=`, at the cursor. + let parts_line = { + let (mut alive, mut drawn) = (0usize, 0usize); + let mut cards: Vec = Vec::new(); + for (w, vv, gt, card) in stores.objects.iter() { + if w.kind == obj.kind && w.id == obj.id { + alive += 1; + drawn += usize::from(vv.get()); + if card { + // A glow card's live world scale — the placement scale times the bone's + // pulse; a doubled or squared pulse reads here. + cards.push(format!("{:.2}", gt.compute_transform().scale.x)); + } + } + } + if cards.is_empty() { + format!("parts alive {alive}, drawn {drawn}") + } else { + format!( + "parts alive {alive}, drawn {drawn}, cards {} (scale {})", + cards.len(), + cards.join(" ") + ) + } + }; let (stores, kinds, collision, lit, motion, go_anims) = ( &stores.stores, &stores.kinds, @@ -592,6 +631,7 @@ pub(super) fn inspect_ui( if let Some(line) = &tag_line { lines.push(line.clone()); } + lines.push(parts_line.clone()); lines.push(format!("{:.1} yd away", mouseover.distance)); // The inspector owns left-click while armed (player::control suppresses left-orbit during inspect), diff --git a/crates/benilla-app/src/net/apply/npc.rs b/crates/benilla-app/src/net/apply/npc.rs index 608650fe..355bd322 100644 --- a/crates/benilla-app/src/net/apply/npc.rs +++ b/crates/benilla-app/src/net/apply/npc.rs @@ -34,9 +34,11 @@ fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> /// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed /// (`crate::ui_gossip`) reads. A first visit to the text id sends the ask-once -/// `CMSG_NPC_TEXT_QUERY` and the menu **stays closed until [`npc_greeting`] answers it** (B292's -/// hold — the mechanics and the reference law live on [`GossipState::open_menu`]); a revisit -/// serves from the cache and opens right away. [`gossip_complete`] closes it. +/// `CMSG_NPC_TEXT_QUERY` and **the feed fires nothing until [`npc_greeting`] answers it** — a +/// hidden frame stays hidden, an open one keeps its previous menu painted (B292's hold and +/// 1994's no-event edge; the mechanics and the reference law live on +/// [`GossipState::open_menu`]); a revisit serves from the cache and repaints right away. +/// [`gossip_complete`] closes it. /// /// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it /// (`0x4e2010`), and the draw needs both this NPC's gender ([`npc_gender`]) and a fresh roll. diff --git a/crates/benilla-app/src/ui_gossip.rs b/crates/benilla-app/src/ui_gossip.rs index 790b430f..e7b900c3 100644 --- a/crates/benilla-app/src/ui_gossip.rs +++ b/crates/benilla-app/src/ui_gossip.rs @@ -11,13 +11,18 @@ //! guarded, never sent — decision 0081), and `CloseGossip` → a local clear (vanilla's client-side //! close sends no packet; verified against the 1.12 opcode set — there is no `CMSG_GOSSIP_CLOSE`). //! -//! **The menu opens only with its greeting resolved** — a first visit to a text id keeps the frame -//! closed for the query round trip instead of showing options over an empty page (B292). That is -//! the reference's own law, VERIFIED at the bytes (wow-re `gossip-npctext-law.md` §4): its greeting -//! write and `GOSSIP_SHOW` are adjacent and unconditional on one success path, every other exit of -//! `0x4e2010` fires no event, so "gossip frame open with a blank greeting" is not a reachable -//! state. [`snapshot`] encodes the hold; [`GossipState::open_menu`]/[`GossipState::text_arrived`] -//! are the two wire edges that resolve it. +//! **The menu opens only with its greeting resolved, and the hold fires nothing** — a first visit +//! to a text id keeps the frame exactly as it was for the query round trip: hidden if it was +//! hidden (never options over an empty page — B292, decision 1508), and **still painted with the +//! previous menu if it was open** (a sub-menu's first visit repaints in place when its text lands, +//! never hides and re-shows the window — decision 1994). Both are the reference's own law, +//! VERIFIED at the bytes (wow-re `gossip-npctext-law.md` §1/§4): on a cache miss `0x4e2010` sets +//! its select latch and returns — no greeting write, no event — and its greeting write and +//! `GOSSIP_SHOW` are adjacent and unconditional on the one success path, so "gossip frame open +//! with a blank greeting" is not a reachable state, and neither is "gossip frame hidden by a +//! pending reply". [`GossipState::text_pending`] is the latch; [`feed_gossip`] fires nothing +//! while it holds; [`GossipState::open_menu`]/[`GossipState::text_arrived`] are the two wire +//! edges that set and resolve it. use std::collections::HashMap; @@ -49,9 +54,10 @@ pub(crate) struct GossipState { /// The open menu's `NpcText` id (drives the text query / cache). pub(crate) text_id: u32, /// The greeting drawn for THIS menu-open, or `None` while its query is in flight — the state - /// [`snapshot`] holds the menu closed on (B292; module doc). A record that names no line - /// ("Missing gossip text!") never parks here: [`GossipState::resolve_greeting`] ends the - /// interaction instead, as the reference does. + /// [`GossipState::text_pending`] reads, on which the feed fires nothing and the drains refuse + /// selects (B292; module doc). A record that names no line ("Missing gossip text!") never + /// parks here: [`GossipState::resolve_greeting`] ends the interaction instead, as the + /// reference does. pub(crate) greeting: Option, /// The selectable option rows (wire `GossipOption`: `index` echoed on select, `icon`, `coded`, /// `message`). @@ -86,12 +92,21 @@ impl GossipState { select_greeting(blocks, npc_gender, greeting_roll()).map(str::to_string) } + /// The reference's text-pending latch (`[0xbbb670]`, wow-re `gossip-npctext-law.md` §1): a + /// session is latched but its greeting has not resolved — its `CMSG_NPC_TEXT_QUERY` is in + /// flight, or (`text_id == 0`) was never sent. While it holds, [`feed_gossip`] fires nothing + /// and both drains refuse every select (decisions 1508, 1994). + pub(crate) fn text_pending(&self) -> bool { + self.npc.is_some() && self.greeting.is_none() + } + /// A gossip menu arrived (`SMSG_GOSSIP_MESSAGE`): latch the session. Returns `true` when the /// greeting still needs its `CMSG_NPC_TEXT_QUERY` (first visit to this text id) — the caller - /// sends it, and the menu stays closed until [`Self::text_arrived`] resolves it (B292's hold, - /// module doc). A cached record opens the menu right here; a `text_id` of 0 never queries and - /// never opens — the reference's `DBCache::Get` refuses id 0 before sending anything, so its - /// frame stays closed with option selects latched off, which is what our pending state is. + /// sends it, and the frame keeps whatever it shows until [`Self::text_arrived`] resolves it + /// (the hold, module doc). A cached record resolves the menu right here; a `text_id` of 0 + /// never queries and never resolves — the reference's `DBCache::Get` refuses id 0 before + /// sending anything, so its frame stays as it was with option selects latched off, which is + /// what our pending state is. pub(crate) fn open_menu( &mut self, npc: u64, @@ -258,9 +273,10 @@ const GOSSIP_ICON_TYPES: [&str; 14] = [ "gossip", ]; -/// Build the Lua-facing snapshot from [`GossipState`] — `None` when no menu is open, **and while -/// the greeting query is in flight**: the second `?` is B292's hold (module doc). The frame opens -/// once, complete, when the text answers — never options over an empty page. +/// Build the Lua-facing snapshot from [`GossipState`] — `None` when no menu is open. The second +/// `?` is the hold's type-level edge (a `GossipMenu` always has its greeting — B292, module doc); +/// [`feed_gossip`] gates on [`GossipState::text_pending`] before ever calling this, so a `None` +/// it sees means no session, never "pending" — a pending frame keeps its last snapshot. fn snapshot(state: &GossipState) -> Option { state.npc?; Some(GossipMenu { @@ -311,6 +327,27 @@ fn feed_gossip( let last = last.get(&script); let last_name = last_name.get(&script); let last_npc = last_npc.get(&script); + // The gossip NPC's name resolves through the NameCache (ask-once — the merchant feed's pattern), + // delivered as arg1 of GOSSIP_SHOW; `None`/empty while the query is in flight, and the diff below + // tracks it so a name-only landing still re-fires GOSSIP_SHOW to repaint the title. It rides an + // event arg rather than a `GossipMenu` field so no benilla-ui engine change is needed for the title. + // Asked ahead of the hold, so a first visit's name query goes out beside its text query rather + // than a round trip after it. + let npc_name = state + .npc + .and_then(|g| names.resolve(g, &commands).map(str::to_string)); + // **The hold fires nothing** (decision 1994). While the greeting query is in flight the + // reference's handler has set its select latch and RETURNED — no greeting write, no event + // (wow-re `gossip-npctext-law.md` §1/§4) — so its frame keeps whatever it last painted: + // hidden if it was hidden, the previous menu if it was open, clicks refused (`drain_gossip`). + // A pending menu is therefore NOT a closed one: the VM keeps its last snapshot, `last` keeps + // its memory, and the text landing is a plain in-place `GOSSIP_SHOW` below. Reading the hold + // as a close (the snapshot going `Some → None`) hid the open window for the round trip and + // re-showed it when the text answered — the flash on a sub-menu's first visit, first visit + // only because the record cache serves every later one without a hold. + if state.text_pending() { + return; + } let mut fresh = snapshot(&state); // Expand the greeting's chat-text macros ($N/$B/$G/$w) client-side, as the real client does. if let Some(greeting) = fresh.as_mut().map(|m| &mut m.greeting) { @@ -323,22 +360,14 @@ fn feed_gossip( }, ); } - // The gossip NPC's name resolves through the NameCache (ask-once — the merchant feed's pattern), - // delivered as arg1 of GOSSIP_SHOW; `None`/empty while the query is in flight, and the diff below - // tracks it so a name-only landing still re-fires GOSSIP_SHOW to repaint the title. It rides an - // event arg rather than a `GossipMenu` field so no benilla-ui engine change is needed for the title. - let npc_name = state - .npc - .and_then(|g| names.resolve(g, &commands).map(str::to_string)); let name_changed = *last_name != npc_name; // A different NPC while the menu is already open is a real close+open (decision 0096 / // [`crate::ui_session::npc_switched`]); a cross-window switch is handled by OnHide → CloseX on - // panel displacement (decision 0095). Switch is judged on the SHOWN menu's NPC, not the - // session's: a switch into a first-visit hold (open A → pending B) is a plain close now and a - // plain open when B's text answers — pairing them here would fire `GOSSIP_SHOW` on an empty VM - // menu, the blank frame B292 exists to make unreachable. - let shown_npc = if fresh.is_some() { state.npc } else { None }; - let switched = npc_switched(*last_npc, shown_npc); + // panel displacement (decision 0095). Past the hold, `fresh` is `Some` exactly when a session + // is latched, so the shown menu's NPC is the session's — and a switch INTO a first-visit hold + // (open A → pending B) is judged here when B's text answers, as one close+open pair; nothing + // fires at the pending edge. + let switched = npc_switched(*last_npc, state.npc); if fresh == *last && !name_changed && !switched { return; } @@ -355,22 +384,15 @@ fn feed_gossip( match (&*last, &fresh) { // Opened, or the greeting/options/name changed while open → (re)paint via GOSSIP_SHOW. (_, Some(_)) => script.fire_event("GOSSIP_SHOW", name_arg()), - // Closed. - (Some(_), None) => { - script.fire_event("GOSSIP_CLOSED", vec![]); - // When the close is a switch INTO a hold (open A → pending B), OnHide → CloseGossip - // queued a close intent for a session that must survive the wait — consume it, as - // the switched branch does, so the drain doesn't cancel pending B. - if state.npc.is_some() { - let _ = script.take_gossip_close(); - } - } + // Closed — the session is over (`clear`). OnHide → CloseGossip queues a close intent + // for a session already gone; the drain's clear is a no-op, so nothing to consume. + (Some(_), None) => script.fire_event("GOSSIP_CLOSED", vec![]), (None, None) => {} } } *last = fresh; *last_name = npc_name; - *last_npc = shown_npc; + *last_npc = state.npc; } /// Drain the Lua intents: a selected option → `CMSG_GOSSIP_SELECT_OPTION` (mapped to the wire option @@ -386,10 +408,11 @@ fn drain_gossip( }; for pos in script.take_gossip_selects() { let Some(npc) = state.npc else { continue }; - // While the greeting query is in flight the menu isn't open (B292's hold) — refuse the - // select, as the reference does: its `SelectGossipOption` (`0x4e2320`) is silently refused - // while the text-pending latch `0xbbb670` is set (wow-re `gossip-npctext-law.md` §1). - if state.greeting.is_none() { + // While the greeting query is in flight — the frame may still show the previous menu + // (1994) — refuse the select, as the reference does: its `SelectGossipOption` (`0x4e2320`) + // is silently refused while the text-pending latch `0xbbb670` is set (wow-re + // `gossip-npctext-law.md` §1). + if state.text_pending() { debug!( "ui_gossip: SelectGossipOption({pos}) while the text query is in flight — refused" ); @@ -416,8 +439,8 @@ fn drain_gossip( for pos in script.take_gossip_quest_selects() { let Some(npc) = state.npc else { continue }; // Same session-pending rule as the option selects above (the latch there is the verified - // one; these rows belong to the same not-yet-open menu). - if state.greeting.is_none() { + // one; these rows belong to the same not-yet-resolved menu). + if state.text_pending() { debug!( "ui_gossip: SelectGossipQuest({pos}) while the text query is in flight — refused" ); @@ -510,6 +533,254 @@ mod tests { assert_eq!(menu.options.len(), 1, "options open WITH the greeting"); } + // ── The hold fires nothing (decision 1994): the real feed, driven ───────────────────────── + + /// A Lua event recorder for the two gossip events — what the VM saw, in order. + const RECORDER: &str = r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("GOSSIP_SHOW") + f:RegisterEvent("GOSSIP_CLOSED") + f:SetScript("OnEvent", function() table.insert(SEEN, event) end) + "#; + + /// The real [`feed_gossip`] + [`drain_gossip`] over a VM — the [`crate::ui_quest`] harness + /// shape: a minimal `App` with the feed's resources, the script as the non-send resource, + /// the two systems in their plugin order. `prepare` seeds the VM (the recorder, or the stock + /// window); the receiver is what the drain sent. + fn feed_app( + prepare: impl FnOnce(&mut UiScript), + ) -> (App, crossbeam_channel::Receiver) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, (feed_gossip, drain_gossip).chain()); + let mut script = UiScript::new().unwrap(); + prepare(&mut script); + app.insert_non_send_resource(script); + (app, rx) + } + + fn state(app: &mut App) -> Mut<'_, GossipState> { + app.world_mut().resource_mut::() + } + + /// One frame: run the feed, then what the VM saw — the recorder's `SEEN` (drained) and + /// `GetGossipText()` (`""` for nil). + fn tick(app: &mut App) -> (Vec, String) { + app.update(); + let s = app.world_mut().non_send_resource_mut::(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + let seen = s.eval::>("return SEEN").unwrap(); + s.run("SEEN = {}").unwrap(); + let text = s.eval::("return GetGossipText() or ''").unwrap(); + (seen, text) + } + + fn record(line: &str) -> Vec { + vec![NpcTextBlock { + probability: 0.0, + male: line.into(), + female: String::new(), + }] + } + + fn option(label: &str) -> Vec { + vec![GossipOption { + index: 0, + icon: 0, + coded: false, + message: label.into(), + }] + } + + /// The selects the drain actually sent, out of everything on the channel (the feed's own + /// ask-once name query rides it too). + fn selects_sent(rx: &crossbeam_channel::Receiver) -> usize { + rx.try_iter() + .filter(|c| matches!(c, ClientCommand::GossipSelectOption { .. })) + .count() + } + + const GUARD: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT + + /// **The hold fires nothing** (decision 1994) — the director's *"the whole window flashes + /// before it shows the content"* on a Stormwind guard's *"Where is …"* option, first click + /// only. The real feed is driven through the sequence the wire produces: the greeting menu + /// open from the cache, then the select's answer — a NEW `SMSG_GOSSIP_MESSAGE` on the SAME NPC + /// whose text id is a first visit, so the `CMSG_NPC_TEXT_QUERY` round trip — then its + /// `SMSG_NPC_TEXT_UPDATE`. + /// + /// Before 1994 the pending step fired `GOSSIP_CLOSED` (the snapshot went `Some → None` and + /// the feed read that as a close) and the landing fired `GOSSIP_SHOW`: `HideUIPanel` then + /// `ShowUIPanel` across a server round trip — the flash. First click only, because the record + /// cache serves the second visit with no hold. The reference fires nothing at the pending + /// step (wow-re `gossip-npctext-law.md` §1/§4): its frame keeps the previous menu painted, + /// selects refused, until the text answers — then one in-place repaint. + #[test] + fn the_hold_fires_nothing_a_pending_submenu_keeps_the_frame_painted() { + let (mut app, rx) = feed_app(|s| s.run(RECORDER).unwrap()); + + // The greeting menu, its text already cached: opens at once. + { + let mut st = state(&mut app); + st.remember_record(50, record("What are you looking for?")); + assert!(!st.open_menu(GUARD, 50, option("The bank"), Vec::new(), 0)); + } + assert_eq!( + tick(&mut app), + ( + vec!["GOSSIP_SHOW".to_string()], + "What are you looking for?".to_string() + ) + ); + + // "The bank" is clicked; the answer is a new menu on the same NPC with a text id never + // seen — the query is owed, and for the whole round trip NOTHING fires: the first menu + // stays in the VM, and a click on one of its (stale) rows is refused, not sent. + assert!( + state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0), + "first visit to the sub-menu's text: the query is owed" + ); + for _ in 0..3 { + assert_eq!( + tick(&mut app), + (Vec::new(), "What are you looking for?".to_string()), + "the hold fires nothing — the previous menu stays painted" + ); + } + app.world_mut() + .non_send_resource_mut::() + .run("SelectGossipOption(1)") + .unwrap(); + let _ = tick(&mut app); + assert_eq!( + selects_sent(&rx), + 0, + "a select during the hold is refused (the latch)" + ); + + // The text lands: one in-place repaint, never a close — and selects work again. + state(&mut app).text_arrived(51, record("The bank is north of here."), 0); + assert_eq!( + tick(&mut app), + ( + vec!["GOSSIP_SHOW".to_string()], + "The bank is north of here.".to_string() + ) + ); + app.world_mut() + .non_send_resource_mut::() + .run("SelectGossipOption(1)") + .unwrap(); + let _ = tick(&mut app); + assert_eq!(selects_sent(&rx), 1); + + // Controls. A real end of the session still closes … + state(&mut app).clear(); + assert_eq!( + tick(&mut app), + (vec!["GOSSIP_CLOSED".to_string()], String::new()) + ); + // … and a first-ever visit from a closed frame still holds shut (B292) and opens once, + // complete, when its text answers. + assert!(state(&mut app).open_menu(GUARD, 52, option("Goodbye"), Vec::new(), 0)); + assert_eq!( + tick(&mut app), + (Vec::new(), String::new()), + "hidden stays hidden through the hold" + ); + state(&mut app).text_arrived(52, record("Yes?"), 0); + assert_eq!( + tick(&mut app), + (vec!["GOSSIP_SHOW".to_string()], "Yes?".to_string()) + ); + } + + /// The same sequence through the stock `GossipFrame.xml` (the reference's own file, 1751) — + /// the symptom at the window: across the round trip it stays **visible**, plays no close kit, + /// and still lists the first menu's row; the text landing repaints in place, and since + /// `ShowUIPanel` early-returns on a visible frame (0096's own mechanism) that plays no open + /// kit either. Needs client data. + #[test] + fn stock_gossip_frame_stays_visible_through_a_first_visit_submenu() { + use benilla_ui::script::SoundRequest; + let _data = benilla_formats::wow_data_or_skip!(); + let (mut app, _rx) = feed_app(|s| { + s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(crate::ui_script::FixedWidthFont(6.0))); + for f in crate::ui_script::test_ui::GOSSIP_UI { + crate::ui_script::test_ui::load_ui(s, f); + } + crate::ui_script::test_ui::load_ui(s, r"Interface\FrameXML\GossipFrame.xml"); + s.run(RECORDER).unwrap(); + }); + // What the window shows: visible?, the greeting, the first row's label, the kits played. + let window = |app: &mut App| -> (bool, String, String, Vec) { + let mut s = app.world_mut().non_send_resource_mut::(); + ( + s.eval::("return GossipFrame:IsVisible()").unwrap(), + s.eval::("return GossipGreetingText:GetText() or ''") + .unwrap(), + s.eval::("return GossipTitleButton1:GetText() or ''") + .unwrap(), + s.take_sounds(), + ) + }; + + { + let mut st = state(&mut app); + st.remember_record(50, record("What are you looking for?")); + st.open_menu(GUARD, 50, option("The bank"), Vec::new(), 0); + } + let _ = tick(&mut app); + assert_eq!( + window(&mut app), + ( + true, + "What are you looking for?".to_string(), + "The bank".to_string(), + vec![SoundRequest::KitName("igQuestListOpen".into())] + ), + "the greeting menu opens with its kit" + ); + + // The click's answer holds on its text: the window must not so much as blink. + assert!(state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0)); + for _ in 0..3 { + let (seen, _) = tick(&mut app); + assert!(seen.is_empty(), "nothing fires during the hold: {seen:?}"); + assert_eq!( + window(&mut app), + ( + true, + "What are you looking for?".to_string(), + "The bank".to_string(), + Vec::new() + ), + "the window stays up, unchanged and silent, for the round trip — a hide here IS the flash" + ); + } + + // The text lands: repainted in place — new greeting, new row, no kit. + state(&mut app).text_arrived(51, record("The bank is north of here."), 0); + let (seen, _) = tick(&mut app); + assert_eq!(seen, vec!["GOSSIP_SHOW".to_string()]); + assert_eq!( + window(&mut app), + ( + true, + "The bank is north of here.".to_string(), + "Goodbye".to_string(), + Vec::new() + ), + "an in-place repaint: ShowUIPanel on a visible frame is a no-op, no kit" + ); + } + /// A revisit (record cached) opens immediately with no query — the half of B292 the reporter /// saw as "never seen this before": the empty frame is a first-visit-only state. #[test] diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index f22418b6..1058e482 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -1056,3 +1056,238 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() { "and it opened the edit box as SAY" ); } + +/// **A glass window's plate fades in on every hover, not only the first.** A restored window +/// (the cache's `COLOR 0 0 0 0`, the stock row) keeps its nine plate textures at alpha 0 and +/// relies on `FCF_OnUpdate`'s hover arm to lift them to `DEFAULT_CHATFRAME_ALPHA` and drop them +/// back (FloatingChatFrame.lua l.873-877, l.913-916). The director's report (2026-09-05): hovering +/// the chat window shows the tabs and the buttons but no plate. +#[test] +fn a_glass_windows_plate_fades_in_on_every_hover() { + let mut s = chat_frame(); + s.mouse_move(1500.0, 850.0); + for _ in 0..4 { + s.tick(0.016); + s.resolve(); + } + let bg = + |s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() }; + assert_eq!(bg(&mut s), 0.0, "a glass window rests at alpha 0"); + let (x, y): (f32, f32) = s + .eval( + "return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \ + (ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2", + ) + .unwrap(); + for round in 1..=3 { + s.mouse_move(x, y); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); + let a = bg(&mut s); + assert!( + (a - 0.25).abs() < 1e-6, + "hover {round}: the plate fades to DEFAULT_CHATFRAME_ALPHA — got {a} (oldAlpha={:?})", + s.eval::>("return ChatFrame1.oldAlpha").unwrap() + ); + s.mouse_move(1500.0, 850.0); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); + let a = bg(&mut s); + assert!( + a.abs() < 1e-6, + "leave {round}: the plate fades back to its saved alpha — got {a}" + ); + } +} + +/// The same law under the WHOLE shipped manifest, driven the way the app drives it. +#[test] +fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1600.0, 900.0); + super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua"); + let failures = super::load_default_ui(&s); + assert!(failures.is_empty(), "{failures:?}"); + s.resolve(); + s.mouse_move(1500.0, 850.0); + for _ in 0..4 { + s.tick(0.016); + s.resolve(); + } + super::fire_chat_login(&mut s); + s.resolve(); + let bg = + |s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() }; + assert_eq!(bg(&mut s), 0.0, "a glass window rests at alpha 0"); + let (x, y): (f32, f32) = s + .eval( + "return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \ + (ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2", + ) + .unwrap(); + for round in 1..=3 { + s.mouse_move(x, y); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); + // The plate as the renderer receives it: one Background quad over ChatFrame1's rect at + // the faded alpha, black. + let plates: Vec<(Option, f32, Option<[f32; 4]>)> = s + .extract() + .iter() + .filter_map(|q| match &q.content { + QuadContent::Texture { + path: Some(p), + color, + .. + } if p.to_ascii_lowercase().contains("chatframebackground") => { + Some((q.rect, q.alpha, *color)) + } + _ => None, + }) + .collect(); + let (left, bottom): (f32, f32) = s + .eval("return ChatFrame1:GetLeft(), ChatFrame1:GetBottom()") + .unwrap(); + // The texture's own anchors: TOPLEFT (-2, 3) and BOTTOMLEFT (-2, -6) off the frame. + let over_frame1 = plates.iter().find(|(r, _, _)| { + r.is_some_and(|r| { + (r.left - (left - 2.0)).abs() < 1.0 && (r.bottom - (bottom - 6.0)).abs() < 1.0 + }) + }); + assert!( + over_frame1.is_some_and(|(_, a, c)| { + (a - 0.25).abs() < 1e-3 && c.is_some_and(|c| c[0] == 0.0 && c[1] == 0.0 && c[2] == 0.0) + }), + "hover {round}: the extracted plate quad over ChatFrame1 ({left}, {bottom}) — {plates:?}" + ); + let a = bg(&mut s); + assert!( + (a - 0.25).abs() < 1e-6, + "hover {round}: the plate fades to DEFAULT_CHATFRAME_ALPHA — got {a} (oldAlpha={:?}, hover={:?}, hasBeenFaded={:?}, init={:?})", + s.eval::>("return ChatFrame1.oldAlpha").unwrap(), + s.eval::>("return ChatFrame1.hover").unwrap(), + s.eval::>("return ChatFrame1.hasBeenFaded").unwrap(), + s.eval::>("return ChatFrame1.init").unwrap(), + ); + s.mouse_move(1500.0, 850.0); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); + let a = bg(&mut s); + assert!( + a.abs() < 1e-6, + "leave {round}: the plate fades back to its saved alpha — got {a}" + ); + } +} + +/// **The plate comes back after a quick exit and re-entry** (decision 1998 — the director's +/// report: an existing window shows tabs but no plate on hover; a new one shows the plate; the +/// tab menu's opacity slider, used once, makes the hover work again). +/// +/// The stock `FCF_OnUpdate` traps itself: leave the window (the tabs' fade-out is queued with +/// `FCF_ChatTabFadeFinished` as its finished callback), re-enter inside `CHAT_FRAME_FADE_TIME`, +/// and the callback fires `chatFrame.oldAlpha = nil` under the live hover — after which the +/// plate arm (`oldAlpha < DEFAULT_CHATFRAME_ALPHA`) never passes and no leave clears `hover`, so +/// the hover-start re-read never runs either. Two VMs prove the guard is what makes the +/// difference: the bare chat stack (the reference's Lua alone) traps, the shipped manifest (the +/// guard installed by `bootstrap_positions`) does not. The control keeps this from passing +/// vacuously — if the stock Lua or the engine ever stop trapping, it says so, and the guard is +/// then a repair of nothing. +#[test] +fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() { + fn drive(s: &mut UiScript) -> (f64, f64, f64) { + let (x, y): (f32, f32) = s + .eval( + "return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \ + (ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2", + ) + .unwrap(); + let bg = + |s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() }; + s.mouse_move(1500.0, 850.0); + for _ in 0..4 { + s.tick(0.016); + s.resolve(); + } + // A stationary hover past CHAT_TAB_SHOW_DELAY and the ramp: the plate is up. + s.mouse_move(x, y); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + let first = bg(s); + // Out for ~50 ms — inside the 0.15 s fade-out — and back, then stationary. + s.mouse_move(1500.0, 850.0); + for _ in 0..3 { + s.tick(0.016); + s.resolve(); + } + s.mouse_move(x, y); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + let reentry = bg(s); + // Away for a full second, then a fresh hover: whatever the re-entry left behind stays. + s.mouse_move(1500.0, 850.0); + for _ in 0..60 { + s.tick(0.016); + s.resolve(); + } + s.mouse_move(x, y); + for _ in 0..45 { + s.tick(0.016); + s.resolve(); + } + let later = bg(s); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + (first, reentry, later) + } + + // The control: the reference's Lua alone. + let mut bare = chat_frame(); + let (first, reentry, later) = drive(&mut bare); + assert!((first - 0.25).abs() < 1e-6, "control, first hover: {first}"); + assert!( + reentry.abs() < 1e-6 && later.abs() < 1e-6, + "the control must trap — the reference's Lua nils oldAlpha under a live hover \ + (re-entry {reentry}, later {later}); if it no longer does, the guard is a repair of nothing" + ); + assert!( + bare.eval::("return ChatFrame1Tab:IsVisible() and ChatFrame1.oldAlpha == nil and ChatFrame1.hover == 1") + .unwrap(), + "the trapped state the director described: tab up, oldAlpha nil, hover stuck" + ); + + // The shipped interface, with the guard the manifest load installs. + let mut shipped = UiScript::new().unwrap(); + shipped.set_screen_size(1600.0, 900.0); + super::test_ui::load_ui(&shipped, "Interface\\FrameXML\\GlobalStrings.lua"); + let failures = super::load_default_ui(&shipped); + assert!(failures.is_empty(), "{failures:?}"); + shipped.resolve(); + super::fire_chat_login(&mut shipped); + shipped.resolve(); + let (first, reentry, later) = drive(&mut shipped); + assert!((first - 0.25).abs() < 1e-6, "shipped, first hover: {first}"); + assert!( + (reentry - 0.25).abs() < 1e-6, + "shipped: the plate comes back on the quick re-entry — got {reentry}" + ); + assert!( + (later - 0.25).abs() < 1e-6, + "shipped: and on every hover after — got {later}" + ); +} diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 54b85a86..326e6461 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -128,6 +128,23 @@ fn content_kind(c: &QuadContent) -> &'static str { } } +/// Which axes of a texture region's UV mapping run PAST the texture — the reference's tiling idiom +/// (`SetTexCoord(0, n, 0, 1)` repeats the art n times along u) — and so must sample with `Repeat`. +/// Per axis, because the two answers are independent and the wrong one on the bounded axis is a +/// visible bleed (decision 2000): with both axes on `Repeat`, a strip that spans exactly `[0, 1]` +/// in v has its top pixel row filtered against the texture's LAST row. The stance shelf's middle +/// piece keeps an opaque grey row there, and drew it as a hairline along its own top edge at +/// every four-form bar. The same tolerance [`uv_clamp_window`] uses to tell a tiling axis from a +/// whole-texture one, so the two laws cannot disagree about which axis is which. +pub(crate) fn tiling_axes(uv: &UvRect) -> (bool, bool) { + let past = |axis: usize| { + uv.corners + .iter() + .any(|c| !(-0.001..=1.001).contains(&c[axis])) + }; + (past(0), past(1)) +} + /// The half-texel-inset UV window a **cropped** quad may sample — [`UiQuad::uv_clamp`]'s producer /// (decision 1608), `None` when neither axis needs one. /// @@ -1557,12 +1574,14 @@ fn convert_entry( // stance shelf's middle carries one slot per extra form exactly that way // (stock `ShapeshiftBar_Update`). Clamp-sampled it smears the last column // across the extra width instead. Clamp/repeat bake into the `Image`, so this picks - // the tiled GPU image + cache entry, as the `Backdrop` arm's `tile` does below. - let tiled = uv - .corners - .iter() - .flatten() - .any(|c| !(-0.001..=1.001).contains(c)); + // a wrapped GPU image + cache entry — wrapped on the tiling axis ALONE + // (`tiling_axes`, decision 2000): the other axis spans the whole texture, and a + // `Repeat` sampler there is a bleed, not a tile — bilinear at `v = 0` weighs in the + // texture's last row, which on `ShapeshiftBarMiddle` is opaque grey, so every + // four-form stance bar drew a one-px grey hairline along the top of its middle + // strip, over the world. The `Backdrop` arm below keeps both axes on: its bg tiles + // both ways and its edge crops never reach the image edge (`inset_atlas_bleed`). + let wrap = tiling_axes(&uv); let handle = match (path.as_deref(), assets.as_mut()) { (Some(p), Some(a)) => { // The tabard designer's emblem cells (1977): the reference installs a @@ -1573,8 +1592,8 @@ fn convert_entry( a.emblem_mask_texture(blp, images) } else if circular { a.portrait_texture(p, images) - } else if tiled { - a.sprite_texture_tiled(p, images) + } else if wrap != (false, false) { + a.sprite_texture_wrapped(p, wrap, images) } else { a.sprite_texture(p, images) }; @@ -1810,7 +1829,7 @@ fn cursor_icon_quad(pos: Vec2, texture: Handle) -> UiQuad { /// cell. #[cfg(test)] mod uv_clamp_tests { - use super::{uv_clamp_window, UvRect}; + use super::{tiling_axes, uv_clamp_window, UvRect}; /// The bug's own numbers: `POIIcons` is 128², a world-map POI samples cell (7,1), and the /// window has to stop half a texel (`0.5/128`) inside it — a hair below texel row 16's centre @@ -1833,6 +1852,46 @@ mod uv_clamp_tests { assert!(uv_clamp_window(&UvRect::FULL, (128, 128)).is_none()); } + /// The stance shelf's middle strip past two forms: `SetTexCoord(0, n-2, 0, 1)` tiles along u + /// and spans the whole texture in v — only u wraps. Both axes `Repeat` was the four-form + /// bar's hairline (decision 2000). + #[test] + fn a_one_axis_strip_wraps_that_axis_only() { + assert_eq!( + tiling_axes(&UvRect::from_tex_coords([0.0, 2.0, 0.0, 1.0])), + (true, false) + ); + assert_eq!( + tiling_axes(&UvRect::from_tex_coords([0.0, 1.0, 0.0, 3.0])), + (false, true) + ); + } + + /// Three forms: `SetTexCoord(0, 1, 0, 1)` is the whole texture — nothing tiles, the plain + /// clamped sprite serves it. And a crop inside the texture is a cell, never a tile. + #[test] + fn a_bounded_mapping_tiles_nothing() { + assert_eq!( + tiling_axes(&UvRect::from_tex_coords([0.0, 1.0, 0.0, 1.0])), + (false, false) + ); + assert_eq!(tiling_axes(&UvRect::FULL), (false, false)); + assert_eq!( + tiling_axes(&UvRect::from_tex_coords([0.453125, 0.875, 0.0, 1.0])), + (false, false) + ); + } + + /// A mapping past the texture on both axes (a tiled backdrop bg) wraps both — the tiled + /// image's case, unchanged. + #[test] + fn a_two_axis_tile_wraps_both() { + assert_eq!( + tiling_axes(&UvRect::from_tex_coords([0.0, 4.0, 0.0, 2.5])), + (true, true) + ); + } + /// `SetTexCoord(0, n, 0, 1)` on an n-slot strip is the reference's TILING idiom (the stance /// shelf): the repeating axis keeps its exact period, the bounded one still gets its window. #[test] diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs index 351e817c..4b97b5c1 100644 --- a/crates/benilla-app/src/ui_script/manifest.rs +++ b/crates/benilla-app/src/ui_script/manifest.rs @@ -74,9 +74,62 @@ fn bootstrap_positions(script: &UiScript) -> Vec { error!("ui_script: buff-duration layout: {e}"); return vec![format!("buff-duration layout: {e}")]; } + if let Err(e) = install_chat_plate_guard(script) { + error!("ui_script: chat plate guard: {e}"); + return vec![format!("chat plate guard: {e}")]; + } Vec::new() } +/// **A stated repair of a reference defect, installed rather than edited in** (decision 1998): +/// the chat plate's hover fade must survive a quick exit and re-entry. +/// +/// The stock `FCF_OnUpdate` (FloatingChatFrame.lua l.809-987) keeps per-window state across +/// ticks: `hover` (the mouse is over the window), `oldAlpha` (the alpha the plate returns to, and +/// the gate the fade-in needs — `oldAlpha < DEFAULT_CHATFRAME_ALPHA`), and `hasBeenFaded`. Two of +/// its arms disagree about who owns them. The leave arm clears `hover` only inside the textures' +/// fade-out condition (l.913-918); the tabs' fade-out is queued with `FCF_ChatTabFadeFinished` +/// as its `finishedFunc` (l.931/973), which fires `CHAT_FRAME_FADE_TIME` (0.15 s) later and sets +/// `oldAlpha = nil` (l.991) — unconditionally. Re-enter the window inside that 0.15 s and the +/// re-entry's hover-start arm has already run (it keeps `oldAlpha`, still valid); then the tab's +/// fade completes, `oldAlpha` goes nil under a live hover, the plate arm (`chatFrame.oldAlpha +/// and chatFrame.oldAlpha < DEFAULT_CHATFRAME_ALPHA`, l.873) never passes again, and every later +/// leave skips the arm that would clear `hover` — so the hover-start re-read of `oldAlpha` (l.907) +/// never runs either. The tab keeps fading in; the plate never does, for the rest of the session. +/// `FCF_SetWindowAlpha` (the tab menu's opacity slider) reseats `oldAlpha` and is the one way +/// out, which is the shape the director reported: an existing window shows no plate on hover, a +/// new window (opened at `DEFAULT_CHATFRAME_ALPHA`, no fade needed) shows one, and "setting the +/// background to zero once" makes the hover work from then on. The gesture that arms it is +/// ordinary: the scroll buttons sit 32 units outside the window's left edge +/// (`FCF_SetButtonSide`, l.1038) and the hover box reaches only 5 (`MouseIsOver(chatFrame, 45, +/// -10, -5, 5)`), so a flick from the text to a scroll button and back does it. +/// +/// The repair is the smallest one: the tab's finished callback leaves `oldAlpha` alone while the +/// window is hovered (`hover` set), and behaves as the reference's when it is not. The Lua is the +/// reference's own and the engine verbs it uses (`GetCursorPosition`, a texture's `GetAlpha`, the +/// OnUpdate `elapsed`) are settled, so the trap is inferred to be 1.12's as well — a client-side +/// A/B is the director's to run (`./run-ref-client.sh`; the record has the script). Installed from +/// Rust for the durability hook's reasons above: `assets/ui` does not grow (1779), and a repair of +/// a reference defect is not a `ContainerFrameAdapters`-class engine-difference shim (1751 §2). +/// +/// Re-stated rather than wrapped (the reference body is two lines), so re-running it after a +/// `ReloadUI` re-defines the same function instead of stacking. Guarded on the function's +/// presence: the font-registry-only load has no chat files. +pub(super) fn install_chat_plate_guard(script: &UiScript) -> Result<(), String> { + script.run(CHAT_PLATE_GUARD).map_err(|e| e.to_string()) +} + +const CHAT_PLATE_GUARD: &str = r#" +if FCF_ChatTabFadeFinished then + function FCF_ChatTabFadeFinished(chatTab, chatFrame) + chatTab:Hide() + if not chatFrame.hover then + chatFrame.oldAlpha = nil + end + end +end +"#; + /// **Apply `SHOW_BUFF_DURATIONS` to the buff bar once, at load** (1751 window 18). /// /// The reference's `BuffFrame_OnLoad` seats the duration FontStrings and nothing else — the row diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index f26e2c7d..3e541e56 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -737,10 +737,16 @@ fn demo_unit_feed(script: Option>, mut fired: Local Vec<(bool, bool)> { + s.extract() + .into_iter() + .filter_map(|q| match q.content { + QuadContent::Texture { + path: Some(p), + tex_coords, + .. + } if p.ends_with(piece) => Some(tiling_axes(&match tex_coords { + Some(TexCoords::Rect(e)) => UvRect::from_tex_coords(e), + Some(TexCoords::Corners(c)) => UvRect::from_corners(c), + None => UvRect::FULL, + })), + _ => None, + }) + .collect() + }; + + // Four forms (a druid; a GM-learned warrior): the middle strip carries two slots and wraps + // along u ONLY. Both end caps stay clamped on both axes. + s.set_shapeshift_forms(vec![form(2457), form(71), form(768), form(2458)]); + s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]); + s.resolve(); + assert_eq!( + wraps_of(&s, "ShapeshiftBarMiddle"), + vec![(true, false)], + "four forms: the strip tiles along its length and clamps across it" + ); + assert_eq!( + wraps_of(&s, "ShapeshiftBarEnds"), + vec![(false, false), (false, false)], + "the end caps are atlas crops and never tile" + ); + + // Three forms (the plain warrior): one slot, the whole texture — nothing tiles. + s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]); + s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]); + s.resolve(); + assert_eq!(wraps_of(&s, "ShapeshiftBarMiddle"), vec![(false, false)]); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 5b7313e2..d337cd82 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -27,7 +27,7 @@ use benilla_ui::script::UiScript; /// /// **A chain entry needs client data**, so a test that names one has to open with /// `benilla_formats::wow_data_or_skip!()`; [`BAG_UI`] is a list that always does. -pub(super) fn load_ui(s: &UiScript, entry: &str) -> usize { +pub(crate) fn load_ui(s: &UiScript, entry: &str) -> usize { load_entry(s, entry, false, false) } @@ -293,6 +293,27 @@ pub(super) const MERCHANT_UI: &[&str] = &[ "Interface\\FrameXML\\GameTooltip.xml", // app load order: tooltip before merchant ]; +/// The stock gossip window's dependencies in `benilla.toc` order — the list +/// `panel_tests::shipped_gossip_frame_drives_end_to_end` and its siblings load by hand, shared so +/// a feed-level test in `ui_gossip` can drive the reference's own `GossipFrame.xml` (1751). +/// `UIParent.xml` brings the slot manager (`ShowUIPanel`/`HideUIPanel`) the window's OnEvent +/// calls; `ScrollTemplates.xml` is our scroll kit the greeting pane inherits — a missing template +/// is a loader *warning*, so an under-loaded list passes and silently loses the scrollbar. +/// +/// Needs client data: open with `benilla_formats::wow_data_or_skip!()`. +pub(crate) const GOSSIP_UI: &[&str] = &[ + r"Interface\FrameXML\MoneyFrame.lua", + r"Interface\FrameXML\MoneyFrame.xml", + r"Interface\FrameXML\UIParent.xml", + "ScrollTemplates.xml", + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\GlobalStrings.lua", + r"Interface\FrameXML\BasicControls.xml", + r"Interface\FrameXML\LocaleProperties.lua", + r"Interface\FrameXML\StaticPopup.xml", +]; + pub(super) const LOOT_UI: &[&str] = &[ "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\Fonts.xml", diff --git a/crates/benilla-app/src/ui_script/ui_menu_tests.rs b/crates/benilla-app/src/ui_script/ui_menu_tests.rs new file mode 100644 index 00000000..22364e13 --- /dev/null +++ b/crates/benilla-app/src/ui_script/ui_menu_tests.rs @@ -0,0 +1,130 @@ +//! The reference's `UIMenu` kit (`Interface\FrameXML\UIMenu.xml` + `UIMenu.lua`) driven as the +//! chat bubble drives it — a row is a `UIMenuButtonTemplate` whose label is the Button's OWN text +//! (`UIMenu_AddButton` → `button:SetText(text)`, no `` in the template) and whose +//! shortcut is a sibling FontString anchored `RIGHT`. `UIMenu_Initialize` fixes every row at +//! 104×16 and the menu at 128 wide; nothing auto-sizes in 1.12. +//! +//! What places the label is the template's ``: `SetText`'s lazy label creation funnels through the adopter +//! `CSimpleButton::SetFontString 0x778d20`, which anchors an unanchored label to the button by the +//! NORMAL embedded font's justify word (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER — +//! wow-re `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5.2, VERIFIED). Decision 1996. + +use benilla_ui::script::UiScript; + +use super::test_ui::load_ui as load_xml; + +/// The chat stack the menu lives in — the same chain `chat_tests` loads for the window, with the +/// fixed-advance font so the rows have real widths in the tick that asks. +fn chat_menu() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + for f in [ + r"Interface\FrameXML\Fonts.xml", + r"Interface\FrameXML\MoneyFrame.lua", + r"Interface\FrameXML\MoneyFrame.xml", + r"Interface\FrameXML\UIParent.xml", + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\GlobalStrings.lua", + r"Interface\FrameXML\BasicControls.xml", + r"Interface\FrameXML\LocaleProperties.lua", + r"Interface\FrameXML\StaticPopup.xml", + r"Interface\FrameXML\GameTooltip.xml", + r"Interface\FrameXML\UIMenu.xml", + r"Interface\FrameXML\ChatFrame.xml", + r"Interface\FrameXML\UIDropDownMenu.xml", + r"Interface\FrameXML\FloatingChatFrame.xml", + ] { + load_xml(&s, f); + } + s.set_text_measurer(Box::new(super::FixedWidthFont(8.0))); + s.fire_event("PLAYER_ENTERING_WORLD", vec![]); + let _ = s.errors(); + s +} + +fn edges(s: &mut UiScript, name: &str) -> (f32, f32) { + s.eval::<(f32, f32)>(&format!("return {name}:GetLeft(), {name}:GetRight()")) + .unwrap_or_else(|e| panic!("{name}'s rect: {e}")) +} + +/// **The label hugs the row's LEFT edge and the shortcut its RIGHT** — the reference's own +/// anchoring, so "Macro" and "/macro" never meet (B-report: the labels drew centred and the +/// Macro row read "Macro/macro"). +#[test] +fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = chat_menu(); + s.run("ChatMenu:Show()").unwrap(); + assert!(s.errors().is_empty(), "opening it raises: {:?}", s.errors()); + s.resolve(); + + // The Macro row is the tenth `UIMenu_AddButton` in `ChatMenu_OnLoad` (ChatFrame.lua l.2301). + assert_eq!( + s.eval::("return ChatMenuButton10:GetText()") + .unwrap(), + "Macro" + ); + assert_eq!( + s.eval::("return ChatMenuButton10ShortcutText:GetText()") + .unwrap(), + "/macro" + ); + // The control: `UIMenu_Initialize` fixed the row at 104 wide; nothing here may resize it. + let (row_l, row_r) = edges(&mut s, "ChatMenuButton10"); + assert_eq!(row_r - row_l, 104.0, "UIMENU_BUTTON_WIDTH"); + + // The label's implicit anchor is LEFT→LEFT (0,0) — the adopter reading the template's + // `` — so its left edge IS the row's. + let (p, rp, x, y): (String, String, f32, f32) = s + .eval( + "local p, _, rp, x, y = ChatMenuButton10:GetFontString():GetPoint(1) \ + return p, rp, x, y", + ) + .unwrap(); + assert_eq!( + (p.as_str(), rp.as_str(), x, y), + ("LEFT", "LEFT", 0.0, 0.0), + "the label is anchored by the normal font's justify" + ); + let (label_l, label_r) = edges(&mut s, "ChatMenuButton10:GetFontString()"); + assert_eq!(label_l, row_l, "the label starts at the row's left edge"); + // The shortcut is the template's own `` string. + let (short_l, short_r) = edges(&mut s, "ChatMenuButton10ShortcutText"); + assert_eq!(short_r, row_r, "the shortcut ends at the row's right edge"); + // And the two never overlap — with 8-px glyphs "Macro" spans 40 and "/macro" 48 of the 104, + // which a centred label (32..72 against 56..104) fails. + assert!( + label_r <= short_l, + "label {label_l}..{label_r} collides with shortcut {short_l}..{short_r}" + ); + + // Every row, not only the one the report named. + for i in 1..=s.eval::("return ChatMenu.numButtons").unwrap() { + let row = format!("ChatMenuButton{i}"); + let (rl, _) = edges(&mut s, &row); + let (ll, _) = edges(&mut s, &format!("{row}:GetFontString()")); + assert_eq!(ll, rl, "row {i}'s label starts at its left edge"); + } +} + +/// The label's *query* surface agrees with its paint: a lazily created label answers the normal +/// font's justify and object (the adopter links it on the spot — `0x779810`), not a fresh +/// FontString's defaults. +#[test] +fn a_menu_rows_label_reports_the_normal_fonts_justify_and_object() { + let _data = benilla_formats::wow_data_or_skip!(); + let s = chat_menu(); + s.run("ChatMenu:Show()").unwrap(); + assert_eq!( + s.eval::("return ChatMenuButton1:GetFontString():GetJustifyH()") + .unwrap(), + "LEFT" + ); + assert!( + s.eval::("return ChatMenuButton1:GetFontString():GetFontObject() == GameFontNormal") + .unwrap(), + "the label inherits the button's normal font object" + ); +} diff --git a/crates/benilla-assets/src/world_assets/images.rs b/crates/benilla-assets/src/world_assets/images.rs index 7e296e58..af9f0711 100644 --- a/crates/benilla-assets/src/world_assets/images.rs +++ b/crates/benilla-assets/src/world_assets/images.rs @@ -272,7 +272,27 @@ pub fn portrait_image(width: u32, height: u32, mut rgba: Vec) -> Image { /// slice must *wrap*, not clamp-stretch. The real client flags exactly this on the backdrop's /// `SetTexture` (arg2 pushed twice into the load descriptor — `backdrop-mechanism.md` §2, INFERRED /// U+V wrap). sRGB + no mips, same as the clamp sprite (UI art, one authored gamma round-trip). +/// +/// Both axes wrap — the backdrop's bg tiles both ways, and its edge strips are atlas crops on +/// their bounded axis, kept off the image edge by `inset_atlas_bleed`. A texture that tiles +/// along ONE axis and spans the whole image on the other takes [`sprite_image_wrapped`] instead +/// (decision 2000). pub fn sprite_image_tiled(width: u32, height: u32, rgba: Vec) -> Image { + sprite_image_wrapped(width, height, rgba, (true, true)) +} + +/// [`sprite_image`]'s decode with the address mode chosen **per axis**: `Repeat` where `wrap` +/// says so, `ClampToEdge` elsewhere. +/// +/// The reference's tiling idiom, `SetTexCoord(0, n, 0, 1)` on an n-slot strip, runs past the +/// texture along one axis only; the other spans exactly `[0, 1]`. Sampling that bounded axis +/// with `Repeat` is a bleed: bilinear filtering at `v = 0` weighs in the texture's LAST row, so a +/// strip whose bottom row is opaque draws that row as a faint hairline along its own top edge — +/// the stance shelf's middle piece (`ShapeshiftBarMiddle.blp`: rows 0-7 transparent, row 31 +/// opaque grey) wore a one-device-px grey line across the top of the strip at every four-form +/// bar, over the world. Wrapping only the axis that actually tiles is the fix at the root: the +/// bounded axis clamps at its edge, exactly as a stand-alone clamped sprite would (decision 2000). +pub fn sprite_image_wrapped(width: u32, height: u32, rgba: Vec, wrap: (bool, bool)) -> Image { let mut image = Image::new( Extent3d { width, @@ -284,9 +304,16 @@ pub fn sprite_image_tiled(width: u32, height: u32, rgba: Vec) -> Image { TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::RENDER_WORLD, ); + let mode = |repeat: bool| { + if repeat { + ImageAddressMode::Repeat + } else { + ImageAddressMode::ClampToEdge + } + }; image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { - address_mode_u: ImageAddressMode::Repeat, - address_mode_v: ImageAddressMode::Repeat, + address_mode_u: mode(wrap.0), + address_mode_v: mode(wrap.1), mag_filter: ImageFilterMode::Linear, min_filter: ImageFilterMode::Linear, ..default() @@ -442,3 +469,42 @@ mod tests { assert_eq!(image_gpu_bytes(&rgba), 84); } } + +#[cfg(test)] +mod wrap_tests { + use super::*; + + fn modes(image: &Image) -> (ImageAddressMode, ImageAddressMode) { + match &image.sampler { + ImageSampler::Descriptor(d) => (d.address_mode_u, d.address_mode_v), + other => panic!("a sprite carries its own sampler, got {other:?}"), + } + } + + /// The stance shelf's case: tiles along its length, spans the whole texture in height — the + /// height axis must CLAMP, or the strip's opaque bottom row bleeds into its top edge. + #[test] + fn a_one_axis_tile_wraps_that_axis_and_clamps_the_other() { + let img = sprite_image_wrapped(2, 2, vec![0; 16], (true, false)); + assert_eq!( + modes(&img), + (ImageAddressMode::Repeat, ImageAddressMode::ClampToEdge) + ); + let img = sprite_image_wrapped(2, 2, vec![0; 16], (false, true)); + assert_eq!( + modes(&img), + (ImageAddressMode::ClampToEdge, ImageAddressMode::Repeat) + ); + } + + /// The backdrop's case is unchanged: both axes wrap. + #[test] + fn the_tiled_sprite_still_wraps_both_axes() { + let img = sprite_image_tiled(2, 2, vec![0; 16]); + assert_eq!( + modes(&img), + (ImageAddressMode::Repeat, ImageAddressMode::Repeat) + ); + assert_eq!(img.texture_descriptor.format, TextureFormat::Rgba8UnormSrgb); + } +} diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index 3f047702..03e6f270 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -57,11 +57,13 @@ pub struct WorldAssets { /// re-walks the chain per frame). Separate from `textures`: sprites are sRGB/clamped, world /// art is Unorm/repeat — the same BLP can legitimately live in both. sprites: HashMap>>, - /// Decoded UI sprite textures sampled with **repeat** addressing (frame `Backdrop` pieces — - /// tiled edges/bg). Separate cache from `sprites`: the *same* BLP can be wanted clamp-sampled as - /// a plain sprite and repeat-sampled as a backdrop piece, and the two need distinct GPU images - /// (the sampler is baked into the `Image`). See [`Self::sprite_texture_tiled`]. - tiled_sprites: HashMap>>, + /// Decoded UI sprite textures sampled with **repeat** addressing on one or both axes (frame + /// `Backdrop` pieces — tiled edges/bg — and the reference's `SetTexCoord(0, n, 0, 1)` strips). + /// Keyed by resolved path **and** the per-axis wrap, separate from `sprites`: the *same* BLP + /// can be wanted clamp-sampled as a plain sprite and repeat-sampled as a backdrop piece, and + /// each address-mode pair needs its own GPU image (the sampler is baked into the `Image`). + /// See [`Self::sprite_texture_wrapped`]. + tiled_sprites: HashMap<(String, bool, bool), Option>>, /// Decoded **portrait** sprites — [`Self::sprite_texture`]'s clamp/sRGB sprite with a circular /// alpha mask baked in ([`portrait_image`]). Its own cache, like `tiled_sprites`: the mask bakes /// into the GPU image, so the same BLP wanted as a plain icon and as a portrait needs two images. @@ -435,22 +437,37 @@ impl WorldAssets { decode_sprite(&self.chain, self.loose_root.as_deref(), path) } - /// A UI sprite decoded with **repeat** (wrap) addressing — the frame `Backdrop` tiled pieces - /// (`backdrop-mechanism.md`): a border edge strip samples UVs `[0..N]` and a tiled bg `[0..w/period]`, - /// so the texture must wrap, not clamp. Same sRGB/no-mip decode + extensionless→`.blp` resolve - /// as [`Self::sprite_texture`], but its own cache (the sampler is baked into the `Image`, so a - /// path wanted both clamp and repeat needs two GPU images). Cached hits **and** misses. + /// A UI sprite decoded with **repeat** (wrap) addressing on both axes — the frame `Backdrop` + /// tiled pieces (`backdrop-mechanism.md`): a border edge strip samples UVs `[0..N]` and a tiled + /// bg `[0..w/period]`, so the texture must wrap, not clamp. [`Self::sprite_texture_wrapped`] + /// with both axes on; see there for the cache and the decode. pub fn sprite_texture_tiled( &mut self, path: &str, images: &mut Assets, ) -> Option> { - let key = sprite_key(path); + self.sprite_texture_wrapped(path, (true, true), images) + } + + /// A UI sprite decoded with **repeat** addressing on the axes `wrap` names and clamp on the + /// rest — the reference's one-axis tiling idiom (`SetTexCoord(0, n, 0, 1)` on the stance + /// shelf's middle strip) wraps along its length and must clamp across it, or the strip's + /// bottom row bleeds into its top edge (decision 2000; [`sprite_image_wrapped`]). Same + /// sRGB/no-mip decode + extensionless→`.blp` resolve as [`Self::sprite_texture`], but its + /// own cache keyed by the wrap pair (the sampler is baked into the `Image`, so a path wanted + /// under two address modes needs two GPU images). Cached hits **and** misses. + pub fn sprite_texture_wrapped( + &mut self, + path: &str, + wrap: (bool, bool), + images: &mut Assets, + ) -> Option> { + let key = (sprite_key(path), wrap.0, wrap.1); if let Some(cached) = self.tiled_sprites.get(&key) { return cached.clone(); } let loaded = decode_sprite(&self.chain, self.loose_root.as_deref(), path) - .map(|(w, h, rgba)| images.add(sprite_image_tiled(w, h, rgba))); + .map(|(w, h, rgba)| images.add(sprite_image_wrapped(w, h, rgba, wrap))); self.tiled_sprites.insert(key, loaded.clone()); loaded } diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index ee60563a..3beda801 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -1,6 +1,7 @@ use mlua::{ObjectLike, Table}; use crate::framexml::{self, Element}; +use crate::script::LabelFont; use super::{ abs_dim, abs_value, children_named, children_named_any, color_of, tex_coords_of, Loader, @@ -69,7 +70,8 @@ impl Loader<'_> { /// plus the generic region layout — ``/``/`setAllPoints` — so a state texture can /// cover less than its button (the merchant row's icon-scoped highlight); `$parent` in a state /// texture's anchors resolves against this button's own name, like a `` region's. - /// Not modeled (stated): per-state fonts/colors, ``. + /// The per-state fonts (`` and kin) are modeled below — object and local justify + /// (decision 1996); not modeled (stated): ``. pub(super) fn apply_button( &mut self, el: &Element, @@ -272,10 +274,22 @@ impl Loader<'_> { // The per-state label fonts (`` etc. — UIPanelButtonTemplate's // gold/white/gray label trio) → the 1.12 setter trio; every occurrence applies in // document order (same last-wins rule as `apply_size`). - for (children, method) in [ - (["NormalFont", "NormalText"], "SetTextFontObject"), - (["HighlightFont", "HighlightText"], "SetHighlightFontObject"), - (["DisabledFont", "DisabledText"], "SetDisabledFontObject"), + for (children, method, which) in [ + ( + ["NormalFont", "NormalText"], + "SetTextFontObject", + LabelFont::Normal, + ), + ( + ["HighlightFont", "HighlightText"], + "SetHighlightFontObject", + LabelFont::Highlight, + ), + ( + ["DisabledFont", "DisabledText"], + "SetDisabledFontObject", + LabelFont::Disabled, + ), ] { for f in children_named_any(el, &children) { // These three are ``-TYPED elements — `CSimpleButton::LoadXML 0x7788c0` @@ -300,17 +314,32 @@ impl Loader<'_> { } } // An element-level justify (``) - // lands on the label region itself — v1: one region-level set (the ref declares - // the same justify on all three state elements; a per-state justify would need - // per-state font-object clones). + // is a LOCAL write on that state's embedded font — the same `0x783c30` the + // `inherits=` above goes through, on `+0x33c`/`+0x3b8`/`+0x434` — never on the + // label. It is what the adopter reads to ANCHOR a label the button later makes + // for itself (`UIMenuButtonTemplate`'s rows: Lua `SetText` → `0x778d20` → + // `[button+0x390]`), and it reaches an existing label only through the live + // link. The v1 form wrote it onto the label directly, which forced a label into + // being here with a CENTER anchor already decided — the chat menu's rows drew + // centred and "Macro" ran into "/macro" (decision 1996). if let Some(j) = f.attr("justifyH") { - // Ensure the slot exists WITHOUT clobbering a label the ``/`text=` - // handling above already set: only a missing slot gets the creating SetText(""). - if wrapper.call_method::("GetFontString", ()).is_err() { - self.call(wrapper, "SetText", String::new(), dbg); - } - if let Ok(region) = wrapper.call_method::
("GetFontString", ()) { - self.call_region(®ion, "SetJustifyH", j.to_string(), dbg); + match crate::justify::parse_h(j) { + crate::justify::Set::To(jh) => { + if let Err(e) = crate::script::set_label_font_justify_h_lua( + self.lua, wrapper, which, jh, + ) { + self.report + .errors + .push(format!("{dbg}: <{}> justifyH: {e}", f.tag)); + } + } + // The reference's parser clears the axis on a cross-axis token and + // raises on a non-token; no 1.12 FrameXML writes either on these + // elements, so both are reported rather than modelled. + _ => self.report.warnings.push(format!( + "{dbg}: <{}> justifyH=\"{j}\" is not a horizontal token — ignored", + f.tag + )), } } } diff --git a/crates/benilla-ui/src/order.rs b/crates/benilla-ui/src/order.rs index 819603da..c05b0e43 100644 --- a/crates/benilla-ui/src/order.rs +++ b/crates/benilla-ui/src/order.rs @@ -22,8 +22,8 @@ //! //! ## The total order (most- to least-significant) //! -//! `(stratum, frame level, draw layer, texture ZKey { + ZKey( + self.0 + | (1u64 << IS_REGION_SHIFT) + | ((layer.index() as u64) << LAYER_SHIFT) + | (128u64 << SUBLEVEL_SHIFT) + | ((BatchRank::Callback as u64) << RANK_SHIFT), ) } @@ -328,7 +376,10 @@ pub struct ZParts { pub level: u16, /// The draw layer (0..=4, [`DrawLayer::index`]) — bucket-wide, above the frame (decision 0884). pub layer: u8, - /// `true` for a font string: all textures of a `(strata, level, layer)` precede all its text. + /// The batch rank: all textures of a `(strata, level, layer)` precede all its text, and all its + /// text precedes its model scenes ([`BatchRank`]). + pub rank: BatchRank, + /// `rank == Text` — kept for the readers that only ask the old question. pub is_fontstring: bool, /// The owning frame's link-stamp — its position in the client's intrusive bucket list. pub insertion: u32, @@ -347,7 +398,12 @@ pub const fn unpack(raw: u64) -> ZParts { strata: ((raw >> STRATUM_SHIFT) & 0xf) as u8, level: ((raw >> LEVEL_SHIFT) & 0xffff) as u16, layer: ((raw >> LAYER_SHIFT) & 0x7) as u8, - is_fontstring: (raw >> FONTSTRING_SHIFT) & 1 == 1, + rank: match (raw >> RANK_SHIFT) & 0x3 { + 0 => BatchRank::Quad, + 1 => BatchRank::Text, + _ => BatchRank::Callback, + }, + is_fontstring: (raw >> RANK_SHIFT) & 0x3 == 1, insertion: ((raw >> INSERTION_SHIFT) & ((1 << INSERTION_BITS) - 1)) as u32, is_region: (raw >> IS_REGION_SHIFT) & 1 == 1, sub_level: (((raw >> SUBLEVEL_SHIFT) & 0xff) as i16 - 128) as i8, @@ -622,6 +678,26 @@ mod tests { b_art < a_text, "a later frame's texture still precedes an earlier frame's fontstring" ); + // …and ALL its font strings precede a model's scene, whatever the link stamps: the scene + // drains from the layer batch's callback list, after its quads and its text (1995). The + // world map's arrow (a Model linked FIRST under WorldMapFrame) over the zone overlays and + // the area label (a later-linked ARTWORK texture and font string) is exactly this pair. + let earlier_model = ZKey::frame(Strata::Medium, 0, 9).callback(DrawLayer::Artwork); + let later_text = ZKey::region(Strata::Medium, 0, 11, DrawLayer::Artwork, 0, true, 0); + assert!( + later_text < earlier_model, + "an earlier-linked model's scene still draws after a later frame's ARTWORK text" + ); + // The rank sits BELOW the layer: an OVERLAY quad still covers an ARTWORK scene. + let b_overlay = ZKey::region(Strata::Medium, 0, 11, DrawLayer::Overlay, 0, false, 0); + assert!( + earlier_model < b_overlay, + "the layer outranks the batch rank" + ); + // Two scenes in one bucket: registration order, which the link stamp carries. + let later_model = ZKey::frame(Strata::Medium, 0, 12).callback(DrawLayer::Artwork); + assert!(earlier_model < later_model); + assert_eq!(unpack(earlier_model.raw()).rank, BatchRank::Callback); // A frame's own slot is benilla's backdrop/scissor hook: it sits BELOW the layer key, so // it precedes only its own regions, starting with its BACKGROUND. diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 5cc22768..4577b02b 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -21,7 +21,8 @@ use mlua::{Lua, MultiValue, ObjectLike, Table, Value}; use super::object::{as_f32, frame_handle_of}; use super::region::region_wrapper; -use super::{event, Model, RegionData}; +use super::{event, JustifyH, Model, RegionData}; +use crate::justify::Justify; use crate::order::DrawLayer; use crate::widget::{ ButtonFont, ButtonState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind, @@ -81,8 +82,57 @@ impl Slot { } } -/// Point the ButtonText at the button's NORMAL font object, so the label's *query* surface answers -/// what its paint already shows. +/// The NORMAL embedded font's justify word — `[button+0x390]` (`+0x33c`, the normal `CSimpleFont`, +/// plus its `+0x54` justify cell): the element-level `` when the template +/// wrote one (a local write on that instance, severed from what it inherits), else what the +/// instance inherits from its font object, else the ctor default (CENTER). The word the label +/// adopter anchors by — decision 1996. +fn normal_font_justify(model: &Model, bs: &ButtonState) -> Justify { + let mut word = Justify::default(); + let inherited = || { + bs.normal_font + .as_deref() + .and_then(|n| model.font_object(n)) + .and_then(|fo| fo.justify_h) + }; + if let Some(j) = bs.normal_justify_h.or_else(inherited) { + word.set_h(j); + } + word +} + +/// `CSimpleButton::SetFontString 0x778d20`'s tail — the ONE path a button label enters by, +/// whether `SetText` made it (`0x778dc0` allocates, then calls this) or Lua handed one over +/// (`SetFontString`): anchor it to the button **only if it has no anchor of its own** and **by the +/// NORMAL font's justify word** (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER — never +/// the string's own `+0x120`, which on a fresh string is the ctor's CENTER), then apply the +/// button's per-state font to it on the spot (`0x779810`). wow-re +/// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5.2, every clause VERIFIED. +/// +/// The word's source is the whole bug this fixes (decision 1996): a `UIMenuButtonTemplate` row +/// has no `` — its label is born from the reference's `UIMenu_AddButton` → +/// `button:SetText(text)` — and its `` is +/// the only thing that puts the label at the row's left edge. Reading the fresh string's own +/// word instead seated every row CENTER, and with the shortcut string anchored RIGHT in a fixed +/// 104-wide row, "Macro" ran into "/macro". +fn adopt_label(model: &mut Model, owner: FrameHandle, rh: RegionHandle) { + let point = { + let Some(frame) = model.arena.frame(owner) else { + return; + }; + let KindState::Button(bs) = &frame.kind_state else { + return; + }; + super::region::justify_anchor_point(normal_font_justify(model, bs).0) + }; + super::region::anchor_unanchored_at(model, rh, point); + apply_normal_font(model, owner); +} + +/// Point the ButtonText at the button's NORMAL font — object and local justify — so the label's +/// *query* surface answers what its paint already shows. The live link the reference's per-state +/// applier `0x779810` → `0x770c60` installs: the instance's field block flows onto the label +/// behind the label's own severance mask. /// /// Before this, the two disagreed. `extract` resolves the per-state font object every frame and /// overlays it onto a CLONE of the region's data (`extract.rs` l.114-122), so the label painted @@ -105,25 +155,76 @@ impl Slot { /// either way, and no corpus site reads a label's font mid-state. /// /// `font::repaint` honours the severance mask, so a `` or `SetTextColor` the label set -/// for itself survives the link (the rule wow-re pinned in `font-object-lua-surface.md`). -fn link_label_to_font_object(lua: &Lua, text: Option, name: Option<&str>) { - let Some(rh) = text else { return }; - let mut model = lua.app_data_mut::().expect("model app_data"); - let Some(name) = name else { - model.region_data.entry(rh).or_default().font_object = None; - return; +/// for itself survives the link (the rule wow-re pinned in `font-object-lua-surface.md`), and the +/// local justify goes behind the same mask. +fn apply_normal_font(model: &mut Model, owner: FrameHandle) { + let (rh, name, local_justify) = { + let Some(frame) = model.arena.frame(owner) else { + return; + }; + let KindState::Button(bs) = &frame.kind_state else { + return; + }; + let Some(rh) = bs.text else { + return; + }; + (rh, bs.normal_font.clone(), bs.normal_justify_h) }; // An unregistered name is not an error here: `SetTextFontObject` already accepted it, and the // loader's own log-and-continue rule (0068) owns the reporting. - let Some(fo) = model.font_object(name).cloned() else { - return; - }; + let fo = name.as_deref().and_then(|n| model.font_object(n).cloned()); let d = model.region_data.entry(rh).or_default(); - d.font_object = Some(name.to_string()); - super::font::repaint(d, &fo); + match (&name, fo) { + (None, _) => d.font_object = None, + (Some(_), None) => {} + (Some(name), Some(fo)) => { + d.font_object = Some(name.clone()); + super::font::repaint(d, &fo); + } + } + if let Some(j) = local_justify { + if !d.font_explicit.justify_h { + d.justify.set_h(j); + } + } model.touch_measure(rh); } +/// Which of the button's three embedded font instances a `<…Font>` element writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LabelFont { + Normal, + Highlight, + Disabled, +} + +/// `` / `` / `` — the +/// loader-facing write of a per-state embedded font's own justify (`CSimpleButton::LoadXML +/// 0x7788c0` → the `` loader `0x783c30` on `+0x33c`/`+0x3b8`/`+0x434`: a ``-typed +/// element, so the attribute lands on the instance itself, severed from whatever it inherits). +/// No 1.12 Lua verb writes this — the API trio sets the OBJECT each instance inherits, never its +/// local fields — which is why it is crate-internal rather than a method. A label already +/// adopted keeps its anchor (the reference decided that at adoption) while its own justify word +/// follows the normal instance, as the live link propagates the write. +pub(crate) fn set_label_font_justify_h_lua( + lua: &Lua, + wrapper: &Table, + which: LabelFont, + j: JustifyH, +) -> mlua::Result<()> { + let owner = frame_handle_of(lua, wrapper)?; + with_button(lua, wrapper, |bs| match which { + LabelFont::Normal => bs.normal_justify_h = Some(j), + LabelFont::Highlight => bs.highlight_justify_h = Some(j), + LabelFont::Disabled => bs.disabled_justify_h = Some(j), + })?; + if which == LabelFont::Normal { + let mut model = lua.app_data_mut::().expect("model app_data"); + apply_normal_font(&mut model, owner); + } + Ok(()) +} + /// Run `f` over a frame's Button state under one short write borrow. fn with_button( lua: &Lua, @@ -197,19 +298,23 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { }, ); model.touch_layout(); // a region entered the layout gate's read set (decision 0740) - // A freshly built slot region gets the creation-path implicit anchor (decision 1310): - // the reference's C++ string setters SetAllPoints a fresh state texture outright - // (`0x778f9d`/`0x7790db` — fresh means zero anchors, so the conditional form is - // equivalent), and ButtonText creation runs the FontString post-step (`0x778b96` → - // `0x771480`), which seats a fresh label CENTER. The XML loader re-derives after - // applying authored `` (see `loader/widgets.rs`); an EXISTING slot region is - // never touched here — the get half of get-or-create changes no geometry. - super::region::implicit_creation_anchor(&mut model, rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { slot.set(bs, rh); } } + // A freshly built slot region gets its creation-path implicit anchor (decision 1310) + // — an EXISTING slot region is never touched here; the get half of get-or-create + // changes no geometry. Which anchor depends on the slot: the reference's C++ string + // setters SetAllPoints a fresh state texture outright (`0x778f9d`/`0x7790db` — fresh + // means zero anchors, so the conditional form is equivalent), while a fresh LABEL is + // handed to the adopter (`SetText 0x778dc0` → `0x778d20`), which anchors it by the + // button's normal font and links it (decision 1996). The XML loader re-derives after + // applying authored `` (see `loader/widgets.rs`). + match slot { + Slot::Text => adopt_label(&mut model, h, rh), + _ => super::region::implicit_creation_anchor(&mut model, rh), + } rh } }; @@ -357,17 +462,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // (`0x77fd10(parent, 2, 1)` — layer id 2 in the client's own `.rdata 0x811a80` name table). // Always, whatever it was parented to or drawn in before. // · **it is anchored only if it has NO anchors of its own** (a scan of all nine - // `anchorPoints` slots): LEFT/RIGHT/CENTER by the justify bits, to the matching point on - // the button. That is exactly [`super::region::implicit_creation_anchor`]'s FontString arm, - // which already transcribes the same `& 7` → LEFT(1)/RIGHT(4)/else-CENTER chain, so it is - // reused rather than re-written. One stated difference: the reference reads the *button's* - // Normal `CSimpleFont` justify word and ours reads the string's own — they agree in the - // ordinary case, because our extract resolves the button's per-state font onto the label - // every frame anyway. - // - // The fifth clause — apply the button's per-state font immediately — needs no code: our - // extract re-points the label at the current state's font object every frame - // ([`ButtonState::normal_font`]), so binding the label IS applying it. + // `anchorPoints` slots): LEFT/RIGHT/CENTER by the justify bits of the **button's normal + // font** (`[button+0x390]`), to the matching point on the button — [`adopt_label`], the + // same `& 7` → LEFT(1)/RIGHT(4)/else-CENTER chain the FontString post-step runs, over the + // other word. (It used to reuse the post-step and read the string's own word; the two + // only agree when the normal font has no justify — decision 1996.) + // · **the button's per-state font is applied immediately** (`0x779810`) — the same + // [`adopt_label`] links the label to the normal font, so its query surface answers. m.set( "SetFontString", lua.create_function(|lua, args: MultiValue| { @@ -438,7 +539,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { if let Some(r) = model.arena.region_mut(rh) { r.draw_layer = DrawLayer::Artwork; } - super::region::implicit_creation_anchor(&mut model, rh); + adopt_label(&mut model, owner, rh); // A new label, a new owner and a possible new anchor — the layout's read set moved, // and the string's extents are what the button's own `GetTextWidth` reports. model.touch_layout(); @@ -504,11 +605,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "SetTextFontObject", lua.create_function(|lua, (this, font): (Table, Value)| { let name = super::font::resolve("SetTextFontObject", &font)?; - let text = with_button(lua, &this, |bs| { - bs.normal_font.clone_from(&name); - bs.text - })?; - link_label_to_font_object(lua, text, name.as_deref()); + let owner = frame_handle_of(lua, &this)?; + with_button(lua, &this, |bs| bs.normal_font = name)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + apply_normal_font(&mut model, owner); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 2fee8611..d0eff449 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -235,6 +235,9 @@ impl UiScript { // (UIPanelButtonTemplate's gold/white/gray trio). let mut state_font: Option<&FontObject> = None; let mut state_color: Option<[f32; 4]> = None; + // The current instance's OWN justify (`<…Font justifyH=>`, a local write on + // the embedded font — [`crate::widget::ButtonState::normal_justify_h`]). + let mut state_justify: Option = None; // `Button:SetFont` — the face/size/flags written on the button's own embedded // fonts rather than on any object they inherit. let mut button_font: Option<&crate::widget::ButtonFont> = None; @@ -284,16 +287,30 @@ impl UiScript { // shows its label. Every state-colour caller in our own UI ships the // matching font object, so the two readings agree on all of them. let highlighted = hovered || bs.locked_highlight; - let (name, color) = if !bs.enabled && bs.disabled_font.is_some() { - (bs.disabled_font.as_ref(), bs.disabled_color) - } else if bs.enabled && highlighted && bs.highlight_font.is_some() { - (bs.highlight_font.as_ref(), bs.highlight_color) - } else { - (bs.normal_font.as_ref(), bs.normal_color) - }; + let (name, color, justify) = + if !bs.enabled && bs.disabled_font.is_some() { + ( + bs.disabled_font.as_ref(), + bs.disabled_color, + bs.disabled_justify_h, + ) + } else if bs.enabled && highlighted && bs.highlight_font.is_some() { + ( + bs.highlight_font.as_ref(), + bs.highlight_color, + bs.highlight_justify_h, + ) + } else { + ( + bs.normal_font.as_ref(), + bs.normal_color, + bs.normal_justify_h, + ) + }; state_font = name.and_then(|n| model.font_object(n)); button_font = bs.font.as_ref(); state_color = color; + state_justify = justify; } } // A TITLE REGION NEVER DRAWS. It is a hit rectangle, not a visual: wow-re @@ -345,13 +362,22 @@ impl UiScript { if !data.font_explicit.color { data.vertex_color = fo.color.or(data.vertex_color); } - // The object's own justify (`` — - // how the ref left-aligns a ButtonText). - if let Some(j) = fo.justify_h { - data.justify.set_h(j); + // The instance's justify: its own `<…Font justifyH=>` (a local write on + // the embedded font, decision 1996), else the object's. Between frames + // the label's own word carries the NORMAL instance's value + // (`button::apply_normal_font`, the live link); a hover or a disable swaps + // it here the way the client re-links the label to another instance + // (`0x779810`) — behind the severance mask like every other axis, so a + // label that `SetJustifyH`'d for itself keeps its own. + if !data.font_explicit.justify_h { + if let Some(j) = state_justify.or(fo.justify_h) { + data.justify.set_h(j); + } } - if let Some(j) = fo.justify_v { - data.justify.set_v(j); + if !data.font_explicit.justify_v { + if let Some(j) = fo.justify_v { + data.justify.set_v(j); + } } } // `Button:SetFont` sits BETWEEN the two: it is a local set on the button's own @@ -474,9 +500,11 @@ impl UiScript { // This is what puts the world map's player arrow over the zone overlays: both frames // sit at `WorldMapFrame.level + 1`, the overlays are ARTWORK quads there, and the // arrow's callback drains after them (the director's report, and the case the carve - // was dispatched on). A model's own OVERLAY/HIGHLIGHT regions still draw over it. + // was dispatched on). A model's own OVERLAY/HIGHLIGHT regions still draw over it. The + // CALLBACK rank (1995) is what puts the scene after that layer's font strings too, + // whatever their link stamps — the content key alone sat at the text rank. let z = match &content { - QuadContent::ModelPane { .. } => zkey.content(order::DrawLayer::Artwork).raw(), + QuadContent::ModelPane { .. } => zkey.callback(order::DrawLayer::Artwork).raw(), _ => zkey.raw(), }; out.push(ExtractedQuad { diff --git a/crates/benilla-ui/src/script/gossip.rs b/crates/benilla-ui/src/script/gossip.rs index f3dfdd7a..0f45bb07 100644 --- a/crates/benilla-ui/src/script/gossip.rs +++ b/crates/benilla-ui/src/script/gossip.rs @@ -16,9 +16,11 @@ //! predicate the XML uses to grey password-gated options (decision 0081 v1: coded options are //! parsed and greyed, never selected; the real client pops a password box, out of scope here). //! `GetGossipText()` returns the greeting body (`SMSG_NPC_TEXT_UPDATE`), `nil` when no menu is -//! open. There is no "menu open, text pending" state: the app holds the menu closed until the -//! greeting resolves, as the reference does (its greeting write and `GOSSIP_SHOW` are adjacent and -//! unconditional on one success path — wow-re `gossip-npctext-law.md` §4; B292), which is why +//! open. There is no "menu open, text pending" state: while a greeting is in flight the app +//! pushes nothing and fires nothing — the VM keeps its last menu (or none), exactly as the +//! reference's frame keeps its last paint (its handler returns on a cache miss without an event, +//! and its greeting write and `GOSSIP_SHOW` are adjacent and unconditional on one success path — +//! wow-re `gossip-npctext-law.md` §1/§4; B292, benilla decisions 1508/1994), which is why //! [`GossipMenu::greeting`] is a plain `String`. use mlua::{Lua, MultiValue, Value}; diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 593aee34..29367a30 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -212,6 +212,7 @@ pub use tabard::{ }; pub use worldmap_arrow::{ARROW_FOOTPRINT_PX, ARROW_MODEL}; +pub(crate) use button::{set_label_font_justify_h_lua, LabelFont}; pub use inspect::{InspectView, UnitReach}; pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState}; pub use item_text::ItemTextState; diff --git a/crates/benilla-ui/src/script/party.rs b/crates/benilla-ui/src/script/party.rs index a783d887..2ebe37b2 100644 --- a/crates/benilla-ui/src/script/party.rs +++ b/crates/benilla-ui/src/script/party.rs @@ -275,7 +275,7 @@ impl super::UiScript { let now = clock(lua); let mut model = self.model_mut(); if we_lead { - if model.ready_check.deadline.is_some() && model.ready_check.unanswered.is_empty() { + if model.ready_check.deadline.is_some() && !ready_check_pending_online(&model) { ready_check_force_close(lua, &mut model); } } else { @@ -283,20 +283,36 @@ impl super::UiScript { } } - /// One member's answer, forwarded to the leader (`{guid, status}`, decision 1989): the - /// member's "has not answered" flag clears (`0x4ba40e`), and once nobody is left pending the - /// check closes at once (`0x4ba4ce` → the worker) rather than at the deadline. - /// - /// **INFERRED, pending wow-re:** the flag clears on ANY answer, ready or not — the flag's - /// meaning is "has not answered yet" (its writer census), and the summary the reference - /// prints is headed "AFK", not "not ready". The polarity of the store at `0x4ba40e` against - /// the wire byte is dispatched for a byte read; `ready` is carried here so that one line - /// changes if the answer says otherwise. - pub fn ready_check_answered(&mut self, guid: u64, _ready: bool) { + /// One member's answer, forwarded to the leader (`{guid, status}`, decisions 1989/1997). + /// The handler's leader arm (`0x4ba360`, wow-re `ui/scratch/readycheck-response-store.md`): + /// the record's guid is matched against the roster entry in full (`0x4ba3f9`/`0x4ba405`), + /// and a match stores **the constant 0** into the "has not answered" flag whatever the status + /// byte says (`0x4ba40e`; `ecx` is zeroed before the loop and never written) — so any answer, + /// ready or not, clears the member. The status byte gates one thing: a `0` prints + /// `RAID_MEMBER_NOT_READY` ("%s is not ready") for that member at arrival (`0x4ba414`), the + /// same raw-`_G` → format → `CHAT_MSG_SYSTEM` path as the summary. Then the close test: once + /// no member is both pending and ONLINE the check closes at once (`0x4ba4ce` → the worker) + /// rather than at the deadline — an offline member who never answered does not hold the + /// check open, and is still listed as AFK when it closes. + pub fn ready_check_answered(&mut self, guid: u64, ready: bool) { let lua = self.lua(); let mut model = self.model_mut(); + let Some(member) = model.party.raid.iter().find(|m| m.guid == guid) else { + return; // an unmatched guid writes nothing (`0x4ba491`) + }; + let name = member.name.clone(); model.ready_check.unanswered.retain(|g| *g != guid); - if model.ready_check.deadline.is_some() && model.ready_check.unanswered.is_empty() { + if !ready { + let template: String = lua + .globals() + .get::("RAID_MEMBER_NOT_READY") + .unwrap_or_default(); + model + .ready_check + .lines + .push(template.replacen("%s", &name, 1)); + } + if model.ready_check.deadline.is_some() && !ready_check_pending_online(&model) { ready_check_force_close(lua, &mut model); } } @@ -918,6 +934,17 @@ fn ready_check_tick(lua: &Lua, model: &mut Model, now: f64) { model.ready_check.lines.push(text); } +/// The `0x322` handler's close predicate (`0x4ba498`–`0x4ba4cc`, decision 1997): a member still +/// holds the check open only while they are BOTH flagged unanswered AND online (`[entry+0x18]` +/// bit 0, the `SMSG_GROUP_LIST` online byte). An empty roster closes at once (`0x4ba3ea`). +fn ready_check_pending_online(model: &Model) -> bool { + model + .ready_check + .unanswered + .iter() + .any(|g| model.party.raid.iter().any(|m| m.guid == *g && m.online)) +} + /// The two C-side force-close sites (`0x4ba4d4`, `0x4bacb4`): the deadline is set to `now − 1` /// and the worker runs at once. The arm test is theirs too — both force only an armed check. fn ready_check_force_close(lua: &Lua, model: &mut Model) { @@ -1660,6 +1687,7 @@ mod tests { let row = |name: &str, guid: u64| crate::script::RaidMemberInfo { name: name.into(), guid, + online: true, ..Default::default() }; PartyState { @@ -1716,15 +1744,16 @@ mod tests { ); } - /// An answer — ready or not — clears its member's flag; the answer that leaves nobody pending - /// closes the check at once (`0x4ba4d9`), well before the deadline. + /// An answer clears its member's flag; the answer that leaves nobody pending (and online) + /// closes the check at once (`0x4ba4d9`), well before the deadline. (A "not ready" answer + /// does the same and prints its line first — the test below.) #[test] fn an_answer_clears_its_member_and_the_last_one_closes_the_check_at_once() { let mut s = UiScript::new().unwrap(); let party = raid_we_lead(&s); s.set_party(party); s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); - s.ready_check_answered(0xA11CE, false); + s.ready_check_answered(0xA11CE, true); assert!( s.take_ready_check_lines().is_empty(), "Bob is still pending" @@ -1739,6 +1768,57 @@ mod tests { assert!(s.take_ready_check_lines().is_empty()); } + /// A "not ready" answer prints `RAID_MEMBER_NOT_READY` for that member the moment it arrives — + /// and clears their flag all the same, because the leader arm stores the constant 0 whatever + /// the byte says (`0x4ba40e`, wow-re `readycheck-response-store.md`). A guid the roster does + /// not hold writes nothing. + #[test] + fn a_not_ready_answer_prints_its_line_and_still_clears_the_member() { + let mut s = UiScript::new().unwrap(); + let party = raid_we_lead(&s); + s.run(r#"RAID_MEMBER_NOT_READY = "%s is not ready""#) + .unwrap(); + s.set_party(party); + s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.ready_check_answered(0xDEAD, false); + assert!( + s.take_ready_check_lines().is_empty(), + "an unmatched guid writes nothing" + ); + s.ready_check_answered(0xA11CE, false); + assert_eq!( + s.take_ready_check_lines(), + vec!["Alice is not ready".to_string()] + ); + s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap(); + assert_eq!( + s.take_ready_check_lines(), + vec!["The following players are AFK: Bob".to_string()], + "Alice answered — not ready is not AFK" + ); + } + + /// An OFFLINE member who never answers does not hold the check open (`0x4ba4a7 test byte + /// [eax+0x18],dl`), but the summary that closes it still lists them: the worker walks the + /// flags with no online test (`0x4bb3a8`). + #[test] + fn an_offline_member_never_blocks_the_close_but_is_still_listed() { + let mut s = UiScript::new().unwrap(); + let mut party = raid_we_lead(&s); + party + .raid + .iter_mut() + .for_each(|m| m.online = m.guid != 0xB0B); // Bob is offline + s.set_party(party); + s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.ready_check_answered(0xA11CE, true); + assert_eq!( + s.take_ready_check_lines(), + vec!["The following players are AFK: Bob".to_string()], + "Alice's answer was the last ONLINE one, so the check closed and listed offline Bob" + ); + } + /// The tick is leader-gated: a member's client arms the same 30 s on the open form and the /// four conjuncts fail on the guid compare, every frame, forever — the deadline stays armed /// (nothing on the member's side ever clears it) and nothing is ever printed. diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index ed6472d5..059d9788 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -640,13 +640,7 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) { ]; } RegionKind::FontString => { - // The exact byte compare chain: `& 7` then equality against LEFT (1) and RIGHT (4) — - // every other value, the CENTER bit and the cleared axis included, falls to CENTER. - let point = match data.justify.0 & crate::justify::H_MASK { - 0x01 => Point::Left, - 0x04 => Point::Right, - _ => Point::Center, - }; + let point = justify_anchor_point(data.justify.0); data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)]; } RegionKind::Title => return, @@ -654,6 +648,42 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) { model.touch_layout(); } +/// The middle-row point a justify word selects — the compare chain the FontString creation +/// post-step (`0x771480`) and the Button label adopter (`CSimpleButton::SetFontString 0x778d20`) +/// share: `& 7`, then equality against LEFT (1) and RIGHT (4); every other value — the CENTER bit +/// and a cleared axis alike — falls to CENTER. What differs between the two callers is only WHOSE +/// word is read: the post-step reads the string's own (`+0x120`), the adopter reads the button's +/// normal font's (`+0x390`) — see [`super::button`]'s `adopt_label` (decision 1996). +pub(crate) fn justify_anchor_point(word: u32) -> crate::layout::Point { + use crate::layout::Point; + match word & crate::justify::H_MASK { + 0x01 => Point::Left, + 0x04 => Point::Right, + _ => Point::Center, + } +} + +/// One middle-row anchor `point → the owner's same point, (0,0)`, installed only when the region +/// has no anchor of its own — the nine-slot scan every implicit-anchor site runs first, so any +/// anchor from any source suppresses it. [`implicit_creation_anchor`]'s FontString arm is this +/// with the region's own justify word; the Button adopter is this with the button's. +pub(crate) fn anchor_unanchored_at( + model: &mut Model, + rh: RegionHandle, + point: crate::layout::Point, +) { + let Some(owner) = model.arena.region(rh).map(|r| r.owner) else { + return; + }; + let owner_id = model.frame_id(owner); + let data = model.region_data.entry(rh).or_default(); + if !data.anchors.is_empty() { + return; + } + data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)]; + model.touch_layout(); +} + /// [`implicit_creation_anchor`] behind a wrapper table — the loader-facing form (the loader holds /// region wrappers, not handles), same seam as [`apply_font_parts`]. pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua::Result<()> { diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index f1b1e080..d752dd19 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -788,3 +788,119 @@ fn set_font_string_anchors_only_an_unanchored_label() { ("CENTER", "CENTER", 0.0, 0.0) ); } + +/// A label the button makes for itself (`SetText`'s lazy creator `0x778dc0`) or adopts +/// (`SetFontString`) is anchored by the BUTTON's normal font justify, not by the fresh string's +/// own ctor-default word: both funnel through `CSimpleButton::SetFontString 0x778d20`, whose +/// unanchored-label leg reads `[button+0x390]` — the NORMAL embedded `CSimpleFont`'s justify — +/// LEFT→LEFT / RIGHT→RIGHT / else CENTER, then links the label to that font (`0x779810`). +/// wow-re `resize-bounds-and-button-fontstring.md` §5.2, VERIFIED; decision 1996. +/// +/// `` is what writes that word in FrameXML (`UIMenuButtonTemplate`, +/// `MailFrame.xml`'s RIGHT money button), so this is the chat menu's "Macro/macro" in miniature. +#[test] +fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() { + let mut s = script(); + s.set_screen_size(1024.0, 768.0); + let doc = crate::framexml::parse( + r#" + + + + + + + + "#, + ) + .expect("valid FrameXML"); + let report = crate::loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "{:?}", report.errors); + + s.run( + r#" + left = CreateFrame("Button", "LeftRow", nil, "LeftRowTemplate") + left:SetPoint("CENTER", nil, "CENTER", 0, 0) + right = CreateFrame("Button", "RightRow", nil, "RightRowTemplate") + right:SetPoint("CENTER", nil, "CENTER", 0, 0) + plain = CreateFrame("Button", "PlainRow", nil, "PlainRowTemplate") + plain:SetPoint("CENTER", nil, "CENTER", 0, 0) + "#, + ) + .unwrap(); + // `` alone creates no label — the reference's LoadXML never touches `+0x338` on + // that leg — so `GetFontString()` is nil until something sets text. + assert!(s + .eval::("return left:GetFontString() == nil") + .unwrap()); + + s.run(r#"left:SetText("Say") right:SetText("12") plain:SetText("Okay")"#) + .unwrap(); + let point = |s: &UiScript, who: &str| -> (String, String, f64, f64) { + let (p, _rel, rp, x, y): (String, mlua::Value, String, f64, f64) = s + .eval(&format!("return {who}:GetFontString():GetPoint(1)")) + .unwrap(); + (p, rp, x, y) + }; + assert_eq!( + point(&s, "left"), + ("LEFT".into(), "LEFT".into(), 0.0, 0.0), + "the element-level justifyH on anchors the lazy label LEFT" + ); + assert_eq!( + point(&s, "right"), + ("RIGHT".into(), "RIGHT".into(), 0.0, 0.0), + "a justify the normal font INHERITS from its object counts the same" + ); + assert_eq!( + point(&s, "plain"), + ("CENTER".into(), "CENTER".into(), 0.0, 0.0), + "no justify anywhere → the else leg, CENTER" + ); + for who in ["left", "right", "plain"] { + assert_eq!( + s.eval::(&format!("return {who}:GetFontString():GetNumPoints()")) + .unwrap(), + 1 + ); + } + // The link is applied on the spot: the label's own word and object answer the normal font's. + assert_eq!( + s.eval::("return left:GetFontString():GetJustifyH()") + .unwrap(), + "LEFT" + ); + assert!(s + .eval::("return left:GetFontString():GetFontObject() == ProbeFont") + .unwrap()); + + // Adoption reads the same word: an unanchored string handed to the LEFT button seats LEFT. + s.run( + r#" + bare = left:CreateFontString(nil, "OVERLAY") + bare:ClearAllPoints() + left:SetFontString(bare) + "#, + ) + .unwrap(); + assert_eq!( + point(&s, "left"), + ("LEFT".into(), "LEFT".into(), 0.0, 0.0), + "SetFontString anchors by the normal font's justify too" + ); + + // A later `SetTextFontObject` re-links the normal font but does NOT re-anchor — the anchor + // was decided at adoption and is an ordinary anchor from then on. + s.run("left:SetTextFontObject(ProbeFontRight)").unwrap(); + assert_eq!(point(&s, "left"), ("LEFT".into(), "LEFT".into(), 0.0, 0.0)); +} diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 1ec0d594..80b31629 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -697,6 +697,25 @@ pub struct ButtonState { pub highlight_font: Option, /// See [`ButtonState::normal_font`] — the disabled state. pub disabled_font: Option, + /// The NORMAL embedded font's own justify — `` (or the `` + /// alias), a **local** write on the instance at `+0x33c` (`CSimpleButton::LoadXML 0x7788c0` + /// → the `` loader `0x783c30`), severed from whatever object the instance inherits and + /// surviving a later `SetTextFontObject`. `None` = the instance shows its object's justify. + /// + /// Two readers, and the first is the one that made this a field of the *button*: the label + /// adopter `CSimpleButton::SetFontString 0x778d20` — the tail `SetText`'s lazy creation and + /// the Lua adopter share — anchors an unanchored label to the button by exactly this word + /// (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER), which is how a row of the + /// reference's `UIMenuButtonTemplate` (no ``, `SetText` from Lua) hugs its left + /// edge. The second is the label's paint and query surface, reached through the live link + /// (`script::button::apply_normal_font`, `script::extract`). Decision 1996. + pub normal_justify_h: Option, + /// See [`ButtonState::normal_justify_h`] — ``, the highlight instance + /// (`+0x3b8`). Paint only: the adopter reads the normal instance alone. + pub highlight_justify_h: Option, + /// See [`ButtonState::normal_justify_h`] — ``, the disabled instance + /// (`+0x434`). Paint only. + pub disabled_justify_h: Option, /// `Button:SetFont(file, height [, flags])` — the button's own face/size/flags, set on the /// embedded font objects themselves rather than on any font object they inherit. See /// [`ButtonFont`] for why one record covers the client's three. @@ -747,6 +766,9 @@ impl Default for ButtonState { normal_font: None, highlight_font: None, disabled_font: None, + normal_justify_h: None, + highlight_justify_h: None, + disabled_justify_h: None, font: None, normal_color: None, highlight_color: None, diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 9d29fa0a..5f532dfc 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -581,7 +581,7 @@ impl WidgetArena { /// `set_frame_level 0x76a4f0` remove-then-add a visible frame — an intrusive-list add appends. /// Called by the [`propagation`] mutators on exactly those transitions (module doc). pub(crate) fn resequence_to_tail(&mut self, h: FrameHandle) { - // The seq lives in a 19-bit field of the packed `ZKey`; unbounded show-bumping could + // The seq lives in an 18-bit field of the packed `ZKey`; unbounded show-bumping could // exhaust it over a marathon session, so at the cap renumber every frame in its current // order (order-preserving, so no visible change) instead of tripping ZKey's assert. if self.next_insertion >= (1 << crate::order::INSERTION_BITS) { diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index 2a79b57f..f55cb811 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -173,7 +173,7 @@ struct TileState { /// own cross-tile dedup (a building straddling N tiles is spawned once and refcounted). The model assets /// are loaded by `Handle`, so the `AssetServer` dedups the *decode*; this dedups the *instance*. #[derive(Resource, Default)] -struct Placements { +pub(crate) struct Placements { /// By MDDF/MODF uniqueId: the placement + how many loaded tiles reference it. by_id: HashMap, /// Material dedup, so submeshes sharing a (texture, blend, sidedness, kind, fade-variant) share one @@ -187,6 +187,18 @@ struct Placements { pending_spawns: usize, } +impl Placements { + /// Every entity some registered placement owns — the set a despawn walks. The duplicate + /// census (`world_census`) subtracts it from the live `WorldObject` population: a placed + /// part alive outside this set outlived its registration, which is what a doubled prop is. + pub(crate) fn owned(&self) -> std::collections::HashSet { + self.by_id + .values() + .flat_map(|p| p.entities.iter().copied()) + .collect() + } +} + /// A shared placement: its resident model handle, world transform, and spawned submesh entities. Doodad /// vs WMO is read off the [`ModelHandle`] variant, so it isn't stored separately. struct Placement { diff --git a/crates/benilla-world/src/world_census.rs b/crates/benilla-world/src/world_census.rs index fc092866..4a766eb2 100644 --- a/crates/benilla-world/src/world_census.rs +++ b/crates/benilla-world/src/world_census.rs @@ -45,6 +45,10 @@ pub struct WorldCensus<'w, 's> { /// Every spawned model submesh, with the facts that make a visible one accountable. parts: Query<'w, 's, CensusData>, emitters: Query<'w, 's, &'static ParticleEmitter>, + /// The placement registry, for the duplicate census (`orphan_parts=`): a placed part alive + /// that no registered placement owns. Optional for a viewer with no streamer. + placements: Option>, + streamer: Option>, /// The exterior-scene gate's two terms (decision 0774) and what the cull actually did with /// them. Optional so the census works in a viewer that has not installed the portal system. claim: Option>, @@ -74,6 +78,13 @@ pub struct WorldCensus<'w, 's> { pub struct CensusReport { /// Every model submesh that exists. pub submeshes: usize, + /// Placed (doodad/WMO) parts alive that no registered placement owns — the duplicate + /// census. Zero in a healthy world; a doubled prop is exactly one of these per part. + pub orphan_parts: usize, + /// The orphans by `(placement id, model label, parts)`, most parts first. + pub orphans: Vec<(u32, String, usize)>, + /// Resident tiles `(furnished, in window)`, off the streamer. + pub tiles: Option<(usize, usize)>, /// …and how many of them the render world will actually draw (`ViewVisibility`). pub drawn: usize, /// Visible submeshes per model subsystem — `(column name, visible, of-those-gated)` — in a @@ -219,8 +230,24 @@ impl WorldCensus<'_, '_> { let (mut tagged, mut hidden, mut exempt_n, mut no_aabb) = (0, 0, 0, 0); let (mut submeshes, mut drawn) = (0usize, 0usize); - for (vis, part, gated, object, want, aabb, card, group, path_why) in self.parts.iter() { + let owned = self.placements.as_ref().map(|p| p.owned()); + let mut orphans: HashMap<(u32, String), usize> = HashMap::new(); + for (entity, vis, part, gated, object, want, aabb, card, group, path_why) in + self.parts.iter() + { submeshes += 1; + // The duplicate census: a doodad/WMO part is spawned by exactly one placement and + // recorded on it; one alive outside every placement's list outlived a respawn. The + // one population that lives outside the registry by design is the retained pass's + // fader EXILES (`static_gx::cull`, decision 1431): the feather-band respawns are + // recorded on their fader seed, not on the placement, and they name themselves. + let exile = path_why.is_some_and(|w| w.0 == "exile"); + if let (Some(owned), Some(o), false) = (owned.as_ref(), object, exile) { + if matches!(o.kind, ModelKind::Doodad | ModelKind::Wmo) && !owned.contains(&entity) + { + *orphans.entry((o.id, o.label.clone())).or_default() += 1; + } + } resident[kind_index(part.kind)] += 1; *why.entry(path_why.map_or("-", |w| w.0)).or_default() += 1; drawn += usize::from(vis.get()); @@ -249,6 +276,9 @@ impl WorldCensus<'_, '_> { } } + let orphan_parts = orphans.values().sum(); + let mut orphans: Vec<_> = orphans.into_iter().map(|((i, l), n)| (i, l, n)).collect(); + orphans.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.cmp(&b.0))); let mut escaped: Vec<_> = escaped.into_iter().map(|((l, c), n)| (l, c, n)).collect(); escaped.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.cmp(&b.0))); let mut labels: Vec<_> = labels.into_iter().map(|((l, g), n)| (l, g, n)).collect(); @@ -283,6 +313,9 @@ impl WorldCensus<'_, '_> { CensusReport { submeshes, drawn, + orphan_parts, + orphans, + tiles: self.streamer.as_ref().map(|s| s.residency()), why: { let mut v: Vec<_> = why.into_iter().collect(); v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); @@ -367,6 +400,7 @@ impl WorldCensus<'_, '_> { /// What the census reads off every model submesh — the query shape. type CensusData = ( + Entity, &'static ViewVisibility, &'static ModelPart, Has,