the gossip window stops flashing, and the chat plate fades in again

- the gossip window stops flashing on a first-visit sub-menu: a guard's "Where is …" option answers `CMSG_GOSSIP_SELECT_OPTION` with a new `SMSG_GOSSIP_MESSAGE` on the same NPC whose text id is a first visit, and 1508's hold made the snapshot `None` while the greeting query was in flight, which the feed read as `Some → None` — `GOSSIP_CLOSED` (`HideUIPanel`) at the select, `GOSSIP_SHOW` (`ShowUIPanel`) when the text landed, the window gone for a server round trip, once per text id because the record cache serves every later visit; the reference's `0x4e2010` on a cache miss sets its select latch (`[0xbbb670] = 1`) and returns with no greeting write and no event, `0x4e1fa0` closes only on a zero guid, and `SelectGossipOption` (`0x4e2320`) is refused while the latch holds — so `GossipState::text_pending` names that latch, `feed_gossip` returns before touching the VM while it holds, both drains refuse on it, the NPC name query is asked ahead of the gate so a first visit's name rides beside its text query, and the landing repaints in place (1994); and the ready check is read off the bytes: `0x4ba40e` stores the constant 0 for any guid-matched answer, the wire byte only gates the `RAID_MEMBER_NOT_READY` print ("%s is not ready") on a 0, `ConfirmReadyCheck` sends one byte, 1 or 0, only while the deadline is armed and unexpired, `DoReadyCheck` sends an empty body, the summary lists only members who never answered, and the early close fires iff no member is both pending AND online (`[entry+0x18]` bit 0) — an offline member who never answered no longer holds the check open and is still listed as AFK when it closes; the `SMSG_GROUP_LIST` leg's close predicate (`0x4ba5f0`) keeps 1989's reading, with no online term (1997)
- the chat menu's rows hug their left edge: a `UIMenuButtonTemplate` row has no `<ButtonText>` — `UIMenu_AddButton` labels it with `SetText` — and the only thing placing the label is `<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>`; the reference's lazy creator (`SetText 0x778dc0`) hands the fresh string to the adopter `0x778d20`, which anchors an unanchored label by the BUTTON's normal embedded font's justify word (`[button+0x390]`: LEFT→LEFT / RIGHT→RIGHT / else CENTER) and links it to that font, where ours anchored by the string's own ctor-default word (CENTER) and the loader wrote the justify onto a force-created label after the anchor was already decided — so every row sat centred in its fixed 104-wide slot and "Macro" ran into "/macro" (1.12's `UIMenu` does not auto-size; `UIMenu_AutoSize` is a later client's); `ButtonState` now carries each state font's own justify, one adopter (`button::adopt_label`) is the tail of both `SetText`'s lazy creation and `SetFontString`, the loader never touches the label, and paint follows the current instance's justify behind the severance mask like every other axis (1996); and the chat plate's hover fade survives a quick exit and re-entry: the stock `FCF_OnUpdate` queues the tabs' fade-out with `FCF_ChatTabFadeFinished` as its finished callback, which nils `chatFrame.oldAlpha` unconditionally 0.15 s later — re-enter inside `CHAT_FRAME_FADE_TIME` and it lands under a live hover, the plate arm's gate never passes again, no later leave clears `hover`, the hover-start re-read never runs, and only `FCF_SetWindowAlpha` (the tab menu's opacity slider) reseats it, which was every clue in the report; the flick from the text to a scroll button and back is the gesture, so an existing window looked broken from a session's first hover; the repair is the smallest guard at the one line — the finished callback leaves `oldAlpha` alone while `hover` is set — installed from Rust after the manifest loads (`manifest::install_chat_plate_guard`), never written into the stock file, and named as a deviation from the reference's own Lua (1998)
- a layer drains its quads, then its text, then its model scenes: `ZKey`'s one font-string bit becomes a two-bit batch rank (Quad, Text, Callback) — the client's layer batch (ctor `0x772e80`) carries its quads at `+0x10`, its `CGxStringBatch` at `+0x18` and a `RENDERCALLBACKNODE` list at `+0x1c`, `0x76fb00` drains them in that order, and `0x76d160` registers a `Model` frame's scene for the ARTWORK batch only — so a `ModelPane` quad takes `ZKey::callback(Artwork)`, after every texture AND every font string of its (strata, level, layer) whatever their link stamps and before anything in the next layer, closing the residual 1992 named on the world map (the arrow is linked first under `WorldMapFrame`, the area label and the overlays later); two scenes in one bucket keep registration order, and the link stamp gives up one bit (19 → 18), which the arena's renumbering at the cap makes unobservable (1995); and the two full-screen vignettes are dead code in 5875: the string `PlayerStatus_OnUpdate` occurs zero times in `WoW.exe`, the per-frame pump runs a frame's OnUpdate by registry reference (`0x70518c lua_rawgeti`, `0x705199 lua_pcall`), the whole `lua_pcall` census has exactly two by-name calls from C, and the only `UIFrameFlash` sites for `LowHealthFrame` and `OutOfControlFrame` in the chain are inside that uncalled function, while the `PLAYER_CONTROL_LOST` arm only sets `UIParent.isOutOfControl` — so 1988's "losing control flashes the vignette" is withdrawn, the two `.blp`s ship and are never loaded, nothing in code changes, and the manifest's note on the two frames says why no driver exists (1999)
- a tiled UI sprite wraps only the axis whose mapping runs past the texture: at four forms `ShapeshiftBar_Update` maps the stance shelf's middle strip `SetTexCoord(0, numForms-2, 0, 1)` — tiling along u, spanning exactly `[0, 1]` in v — and the repeat-sampled image it was served from wrapped BOTH axes, so bilinear at the strip's top pixel row reached `v < 0` and wrapped to the texture's last row, opaque grey on the 32×32 `ShapeshiftBarMiddle.blp` whose rows 0–7 are transparent: a one-device-px hairline along the strip's top edge over the world, reading 0.81–0.84× its neighbours' luma against 1.00 over the clamp-sampled caps (predicted 75 over a world of 100, measured 74–77); 1608's `uv_clamp_window` skipped an axis spanning the whole texture because "the sampler's own clamp already is this", true for the plain sprite and false for the tiled one; `tiling_axes` now decides the wrap per axis with the same tolerance, `WorldAssets::sprite_texture_wrapped` bakes `Repeat` where asked and `ClampToEdge` elsewhere, cached by (path, u, v), and the backdrop pieces and the chat bubble's edge keep both axes on; `WOW_DEMO_BOTTOM_BARS=0` leaves the action-bar demo's bottom bars down, the only state the shelf draws in (2000); the duplicate-placement census rides the probe line (`orphan_parts=`, `tiles=`) and the inspector card (parts alive, parts drawn this frame, each glow card's live world scale): a placed doodad or WMO part is spawned by exactly one placement, so a part alive outside every placement's entity list outlived a respawn, with the retained pass's fader exiles named as the one population legitimately outside the registry

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:31:38 +07:00
co-authored by Claude Fable 5.1
parent 7a825111ab
commit a9b613c85a
27 changed files with 1724 additions and 189 deletions
+4 -1
View File
@@ -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
@@ -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<String> = 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}",
@@ -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<benilla_world::billboard::BillboardCard>,
),
>,
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<String> = 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),
+5 -3
View File
@@ -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.
+318 -47
View File
@@ -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<String>,
/// 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<GossipMenu> {
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/$<n>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<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GossipState>()
.init_resource::<NameCache>()
.init_resource::<crate::world_state::WorldStates>()
.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::<GossipState>()
}
/// 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>, String) {
app.update();
let s = app.world_mut().non_send_resource_mut::<UiScript>();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
let text = s.eval::<String>("return GetGossipText() or ''").unwrap();
(seen, text)
}
fn record(line: &str) -> Vec<NpcTextBlock> {
vec![NpcTextBlock {
probability: 0.0,
male: line.into(),
female: String::new(),
}]
}
fn option(label: &str) -> Vec<GossipOption> {
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<ClientCommand>) -> 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::<UiScript>()
.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::<UiScript>()
.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<SoundRequest>) {
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
(
s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
s.eval::<String>("return GossipGreetingText:GetText() or ''")
.unwrap(),
s.eval::<String>("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]
@@ -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::<Option<f64>>("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<benilla_ui::layout::Rect>, 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::<Option<f64>>("return ChatFrame1.oldAlpha").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.hover").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.hasBeenFaded").unwrap(),
s.eval::<Option<f64>>("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::<bool>("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}"
);
}
@@ -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<Image>) -> 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]
@@ -74,9 +74,62 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
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
+16 -5
View File
@@ -737,10 +737,16 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
// choice about what to photograph, not a default: `MultiActionBar_Update` is the same
// function the row calls, so nothing here is a private door into the bars. Guarded because
// this feed also runs with `WOW_CAPTURE_UI` unset, where no interface has been loaded.
let _ = script.run(
"SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 \
if MultiActionBar_Update then MultiActionBar_Update() end",
);
// `WOW_DEMO_BOTTOM_BARS=0` leaves them down — the shipped default a player logs in to
// (1500), and the only state in which the stance SHELF draws at all: the manage pass
// hides the shelf art whenever the bottom-left bar is up, so a capture of the shelf
// (decision 2000's hairline lived on it) needs the bars where the player has them.
if std::env::var("WOW_DEMO_BOTTOM_BARS").as_deref() != Ok("0") {
let _ = script.run(
"SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 \
if MultiActionBar_Update then MultiActionBar_Update() end",
);
}
script.fire_event("PLAYER_XP_UPDATE", vec![]);
for token in ["player", "target"] {
script.fire_event("UNIT_HEALTH", vec![ScriptValue::Str(token.into())]);
@@ -769,7 +775,7 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont {
}
#[cfg(test)]
mod test_ui;
pub(crate) mod test_ui;
/// The chat loader's two login events (wow-re chat-cache-grammar.md §8; `ui_chat::settings`):
/// `UPDATE_CHAT_WINDOWS` once, then `UPDATE_CHAT_COLOR` for every registry entry. The reference's
@@ -913,6 +919,11 @@ mod group_loot_tests;
#[cfg(test)]
mod chat_tests;
/// The chat bubble's `UIMenu` kit driven as a menu — the rows' label/shortcut anchoring
/// (decision 1996), kept apart from `chat_tests` because it is the kit under test, not the window.
#[cfg(test)]
mod ui_menu_tests;
/// The chat tab's options menu, end to end (decision 1589 / B246) — its own file because it needs
/// the whole dropdown + colour-picker stack under `ChatFrame.xml`, where `chat_tests` deliberately
/// runs on the window alone.
@@ -1638,3 +1638,81 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
assert_eq!(label(&s, "MultiBarBottomLeftButton2"), "");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Decision 2000 — the shelf's middle strip tiles along its LENGTH only.**
///
/// Past two forms `ShapeshiftBar_Update` maps the middle strip `SetTexCoord(0, n-2, 0, 1)`
/// (BonusActionBarFrame.lua l.198-206): one slot of art per extra form, repeated along u — the
/// reference's tiling idiom — while v spans exactly the texture. The renderer serves such a strip
/// from a repeat-sampled image, and until 2000 that image wrapped BOTH axes: bilinear filtering
/// at the strip's top edge weighed in the texture's LAST row (`ShapeshiftBarMiddle.blp` row 31 —
/// opaque grey; rows 0-7 transparent), so every four-form bar wore a one-device-px grey hairline
/// along the top of its middle piece, over the world: 0.81-0.84× the world's luma over the strip
/// against 1.00 over the clamp-sampled end caps, in a live four-form shot.
///
/// The seam this pins is the ask itself: the wrap the shelf's three pieces request of the
/// renderer, derived from the UV mapping the VM extracts — u alone for the middle strip past two
/// forms, nothing at three forms (`SetTexCoord(0, 1, 0, 1)` is the whole texture), and never for
/// the end caps (atlas crops of `ShapeshiftBarEnds`).
#[test]
fn the_middle_strip_tiles_along_its_length_only() {
use super::extract::tiling_axes;
use crate::ui_pass::UvRect;
use benilla_ui::script::{ShapeshiftFormView, TexCoords};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[]); // bars down: the shelf is the state under test
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Form {id}"),
active: id == 2457,
castable: true,
cooldown: None,
};
// Every drawn piece whose texture path ends in `piece`, as the wrap it asks the renderer for.
let wraps_of = |s: &UiScript, piece: &str| -> 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());
}
+22 -1
View File
@@ -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",
@@ -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 `<ButtonText>` 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 `<NormalFont inherits="GameFontNormal"
//! justifyH="LEFT"/>`: `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::<String>("return ChatMenuButton10:GetText()")
.unwrap(),
"Macro"
);
assert_eq!(
s.eval::<String>("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
// `<NormalFont justifyH="LEFT"/>` — 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 `<Anchor point="RIGHT"/>` 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::<i64>("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::<String>("return ChatMenuButton1:GetFontString():GetJustifyH()")
.unwrap(),
"LEFT"
);
assert!(
s.eval::<bool>("return ChatMenuButton1:GetFontString():GetFontObject() == GameFontNormal")
.unwrap(),
"the label inherits the button's normal font object"
);
}
@@ -272,7 +272,27 @@ pub fn portrait_image(width: u32, height: u32, mut rgba: Vec<u8>) -> 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<u8>) -> 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<u8>, 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<u8>) -> 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);
}
}
+29 -12
View File
@@ -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<String, Option<Handle<Image>>>,
/// 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<String, Option<Handle<Image>>>,
/// 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<Handle<Image>>>,
/// 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<Image>,
) -> Option<Handle<Image>> {
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<Image>,
) -> Option<Handle<Image>> {
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
}
+44 -15
View File
@@ -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 — `<Size>`/`<Anchors>`/`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 `<Layers>` region's.
/// Not modeled (stated): per-state fonts/colors, `<PushedTextOffset>`.
/// The per-state fonts (`<NormalFont>` and kin) are modeled below — object and local justify
/// (decision 1996); not modeled (stated): `<PushedTextOffset>`.
pub(super) fn apply_button(
&mut self,
el: &Element,
@@ -272,10 +274,22 @@ impl Loader<'_> {
// The per-state label fonts (`<NormalFont inherits=>` 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 `<Font>`-TYPED elements — `CSimpleButton::LoadXML 0x7788c0`
@@ -300,17 +314,32 @@ impl Loader<'_> {
}
}
// An element-level justify (`<NormalFont inherits="QuestFont" justifyH="LEFT"/>`)
// 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 `<ButtonText>`/`text=`
// handling above already set: only a missing slot gets the creating SetText("").
if wrapper.call_method::<Table>("GetFontString", ()).is_err() {
self.call(wrapper, "SetText", String::new(), dbg);
}
if let Ok(region) = wrapper.call_method::<Table>("GetFontString", ()) {
self.call_region(&region, "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
)),
}
}
}
+86 -10
View File
@@ -22,8 +22,8 @@
//!
//! ## The total order (most- to least-significant)
//!
//! `(stratum, frame level, draw layer, texture<fontstring, frame link-stamp, is-region, sub-level,
//! declaration order)` — packed big-endian-by-priority into one sortable [`ZKey`] (`u64`), so the
//! `(stratum, frame level, draw layer, batch rank quad<text<callback, frame link-stamp, is-region,
//! sub-level, declaration order)` — packed big-endian-by-priority into one sortable [`ZKey`] (`u64`), so the
//! whole render list is produced by one sort on the key. The `is-region` bit is benilla's own: the
//! binary has no frame-level drawable (only regions draw), so it sits *below* the layer key purely
//! to keep a frame's backdrop/scissor slot ahead of that frame's own regions.
@@ -214,15 +214,39 @@ impl Default for DrawLayer {
const STRATUM_SHIFT: u32 = 60;
const LEVEL_SHIFT: u32 = 44;
const LAYER_SHIFT: u32 = 41;
const FONTSTRING_SHIFT: u32 = 40;
/// The batch RANK within a `(strata, level, layer)`: the three sub-arrays a layer batch carries and
/// the order `0x76fb00` drains them — its quads (`+0x10`), its string batch (`+0x18`), then its
/// render-callback list (`+0x1c`; wow-re `ui/scratch/model-frame-draw-order.md`, decision 1995).
/// Two bits at 39..40, where the one font-string bit used to be; the link stamp below lost a bit
/// for it (18, from 19 — the arena renumbers at the cap either way).
const RANK_SHIFT: u32 = 39;
const INSERTION_SHIFT: u32 = 21;
const IS_REGION_SHIFT: u32 = 20;
const SUBLEVEL_SHIFT: u32 = 12;
const DECL_SHIFT: u32 = 0;
pub(crate) const INSERTION_BITS: u32 = 19;
pub(crate) const INSERTION_BITS: u32 = 18;
const DECL_BITS: u32 = 12;
/// Which of a layer batch's three sub-arrays an entry drains from — in the order `0x76fb00` drains
/// them. The client keeps them as three arrays on one batch object (ctor `0x772e80`: quads at
/// `+0x10`, the `CGxStringBatch` at `+0x18`, a `RENDERCALLBACKNODE` list at `+0x1c`) and empties
/// them in this order, so within one `(strata, level, layer)` every texture precedes every font
/// string, and every font string precedes every model scene (wow-re
/// `ui/scratch/model-frame-draw-order.md`; decision 1995).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum BatchRank {
/// A texture quad — drained first.
Quad = 0,
/// A font string — the string batch, drained second.
Text = 1,
/// A `Model` frame's scene — the callback list, drained last. Registered for ARTWORK only
/// (`0x76d17f cmp ebx,2`), which is why [`ZKey::callback`] takes the layer it is asked for
/// but a model's is always `ARTWORK`.
Callback = 2,
}
/// The packed draw-order key: a single `u64` whose natural `Ord` *is* the client's total draw
/// order. Build one with [`ZKey::frame`] or [`ZKey::region`]; sort ascending to get painter order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -271,7 +295,12 @@ impl ZKey {
| (1u64 << IS_REGION_SHIFT)
| (u64::from(layer.index()) << LAYER_SHIFT)
| (sub_biased << SUBLEVEL_SHIFT)
| (u64::from(is_fontstring) << FONTSTRING_SHIFT)
| ((if is_fontstring {
BatchRank::Text
} else {
BatchRank::Quad
}) as u64)
<< RANK_SHIFT
| (u64::from(decl) << DECL_SHIFT);
ZKey(bits)
}
@@ -294,10 +323,29 @@ impl ZKey {
self.0
| (1u64 << IS_REGION_SHIFT)
| ((layer.index() as u64) << LAYER_SHIFT)
// Sub-level 0 in the same +128 bias `region` uses, and the font-string bit: content
// is text, so it takes the same textures-draw-first slot a font string would.
// Sub-level 0 in the same +128 bias `region` uses, and the text rank: content is
// text, so it takes the same textures-draw-first slot a font string would.
| (128u64 << SUBLEVEL_SHIFT)
| (1u64 << FONTSTRING_SHIFT),
| ((BatchRank::Text as u64) << RANK_SHIFT),
)
}
/// The key a `Model` frame's **scene** draws at: its bare [`ZKey::frame`] slot promoted into
/// its region band at `layer` with the CALLBACK rank — after every texture and every font
/// string of that `(strata, level, layer)`, whatever their link stamps. That is the client's
/// own place for it: `0x76d160` registers the model's render callback into the layer batch,
/// and `0x76fb00` drains the callback list after the quads and the text. Two scenes in one
/// bucket keep the link-stamp order below the rank (registration order = FIFO), which the
/// bare slot's own insertion carries here (decision 1995).
#[inline]
#[must_use]
pub const fn callback(self, layer: DrawLayer) -> 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.
+140 -40
View File
@@ -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 `<NormalFont justifyH=>` 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 `<ButtonText>` — its label is born from the reference's `UIMenu_AddButton` →
/// `button:SetText(text)` — and its `<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>` 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 `<FontHeight>` 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<RegionHandle>, name: Option<&str>) {
let Some(rh) = text else { return };
let mut model = lua.app_data_mut::<Model>().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,
}
/// `<NormalFont justifyH=>` / `<HighlightFont justifyH=>` / `<DisabledFont justifyH=>` — the
/// loader-facing write of a per-state embedded font's own justify (`CSimpleButton::LoadXML
/// 0x7788c0` → the `<Font>` loader `0x783c30` on `+0x33c`/`+0x3b8`/`+0x434`: a `<Font>`-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::<Model>().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<T>(
lua: &Lua,
@@ -197,19 +298,23 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
},
);
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 `<Anchors>` (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 `<Anchors>` (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::<Model>().expect("model app_data");
apply_normal_font(&mut model, owner);
Ok(())
})?,
)?;
+43 -15
View File
@@ -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<super::JustifyH> = 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 (`<NormalFont inherits=… justifyH="LEFT"/>` —
// 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 {
+5 -3
View File
@@ -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};
+1
View File
@@ -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;
+95 -15
View File
@@ -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::<String>("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.
+37 -7
View File
@@ -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<()> {
@@ -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.
///
/// `<NormalFont justifyH=>` 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#"<Ui>
<Font name="ProbeFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Font name="ProbeFontRight" inherits="ProbeFont" justifyH="RIGHT" virtual="true"/>
<Button name="LeftRowTemplate" virtual="true">
<Size><AbsDimension x="104" y="16"/></Size>
<NormalFont inherits="ProbeFont" justifyH="LEFT"/>
<HighlightFont inherits="ProbeFont" justifyH="LEFT"/>
</Button>
<Button name="RightRowTemplate" virtual="true">
<Size><AbsDimension x="104" y="16"/></Size>
<NormalFont inherits="ProbeFontRight"/>
</Button>
<Button name="PlainRowTemplate" virtual="true">
<Size><AbsDimension x="104" y="16"/></Size>
<NormalFont inherits="ProbeFont"/>
</Button>
</Ui>"#,
)
.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();
// `<NormalFont>` 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::<bool>("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 <NormalFont> 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::<i64>(&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::<String>("return left:GetFontString():GetJustifyH()")
.unwrap(),
"LEFT"
);
assert!(s
.eval::<bool>("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));
}
+22
View File
@@ -697,6 +697,25 @@ pub struct ButtonState {
pub highlight_font: Option<String>,
/// See [`ButtonState::normal_font`] — the disabled state.
pub disabled_font: Option<String>,
/// The NORMAL embedded font's own justify — `<NormalFont justifyH=>` (or the `<NormalText>`
/// alias), a **local** write on the instance at `+0x33c` (`CSimpleButton::LoadXML 0x7788c0`
/// → the `<Font>` 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 `<ButtonText>`, `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<crate::script::JustifyH>,
/// See [`ButtonState::normal_justify_h`] — `<HighlightFont justifyH=>`, the highlight instance
/// (`+0x3b8`). Paint only: the adopter reads the normal instance alone.
pub highlight_justify_h: Option<crate::script::JustifyH>,
/// See [`ButtonState::normal_justify_h`] — `<DisabledFont justifyH=>`, the disabled instance
/// (`+0x434`). Paint only.
pub disabled_justify_h: Option<crate::script::JustifyH>,
/// `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,
+1 -1
View File
@@ -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) {
+13 -1
View File
@@ -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<u32, Placement>,
/// 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<Entity> {
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 {
+35 -1
View File
@@ -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<Res<'w, crate::terrain_stream::Placements>>,
streamer: Option<Res<'w, crate::terrain_stream::TerrainStreamer>>,
/// 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<Res<'w, CameraInteriorClaim>>,
@@ -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<ExteriorScene>,