windows take turns, the gossip page waits, the shot bar counts down

- the UIPanel registry is re-derived from the reference's bytes: the readable-item page finally registers, so a note or letter closes when a gossip window opens instead of interleaving with it line for line; the character sheet takes its real pushable-2 seat (an NPC window slides it over instead of replacing it), and the spellbook, quest log, talents and character sheet open for dead players again — a refused open now says "You can't do that when you're dead." instead of doing nothing
- the gossip window waits for its greeting: the first packet carries only the options and a text id, and we used to show the frame with a blank page for the round trip the text needed — the menu now exists only once the text has answered, a mid-flight click is refused, and a revisit is instant off the cache
- a bag slot whose stack count changes on the server now fires `ITEM_LOCK_CHANGED` just ahead of its `BAG_UPDATE` — the one signal 1.12 hunter addons use to detect a spent arrow — so Quiver's auto-shot bar drains per shot instead of pinning at 100%; a swap or create deliberately does not fire it, so rearranging bags can't read as a shot
- the outdoor minimap tiles' gamma byte reaches the screen exactly once — the ordinary decode arm learns the same skip the fast path already had

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 18:17:03 +07:00
co-authored by Claude Fable 5
parent 80d8bd48db
commit 312e123fb7
18 changed files with 1204 additions and 204 deletions
@@ -12,10 +12,12 @@
defined in `UiPanels.xml:507-563` (battle-tested by Merchant/QuestLog, 0138) — reused
here, not redefined.
Docks the left UIPanel slot exactly like Gossip/Merchant/Quest/QuestLog (`UiPanels.xml`
`UIPanelWindows["CharacterFrame"] = { area = "left", pushable = 0 }`, mirroring the
ref's own `CharacterFrame` row, `UIParent.lua` pin cited in `UiPanels.xml`'s header) — so it
competes for/replaces those windows, never stacks with them. `ToggleCharacter(tab)` (ref
Docks the left UIPanel slot (`UiPanels.xml` `UIPanelWindows["CharacterFrame"] =
{ area = "left", pushable = 2, whileDead = 1 }`, the ref's own `UIParent.lua l.19` row —
decision 1507; pushable was wrongly 0 from 0208 until then). pushable=2 means it does NOT
simply replace the NPC-session windows: a pushable-0 window opening under it slides this
sheet to the CENTER seat (vendor left, your equipment beside it), and showing this over a
pushable-0 occupant parks it at center likewise. `ToggleCharacter(tab)` (ref
`CharacterFrame.lua:3-23`) is the show/hide/tab-switch entry point;
the app's 'C' binding calls it (`ui_script/mod.rs`).
@@ -13,7 +13,8 @@
construction regardless (`widget/mod.rs`'s ctor comment).
Docks the left UIPanel slot exactly like Gossip/Merchant/Quest (`UiPanels.xml`
`UIPanelWindows["QuestLogFrame"] = { area = "left", pushable = 0 }`, pin §8's
`UIPanelWindows["QuestLogFrame"] = { area = "left", pushable = 0, whileDead = 1 }`
(whileDead carried since 1507), pin §8's
`UIParent.lua l.25` quote) — so it competes for/replaces those windows, never stacks with
them. UNLIKE those windows this is not an NPC session: no `CloseX()` on hide, no range guard;
it is durable player state the app can push at any time (`benilla-ui`'s `quest_log.rs` module
+81 -33
View File
@@ -11,36 +11,51 @@
reference's window arbitration (a different number is a different window order), and the
`UISpecialFrames` seeds. The code around that data is ours.
Scope: `left`, `center` and `full` are all live. `doublewide` and `UIChildWindows` are not
— nothing benilla ships wants them — but the registration table keeps their exact shape so
a later row drops in unchanged. **`UISpecialFrames` is not ours to write into** (decision
1206): it is the table an ADDON puts its own window in, and 34 corpus call sites do.
Scope: `left`, `center` and `full` are all live, and so is `UIChildWindows` (decision 1507
— all four of the reference's child windows ship now). `doublewide` is not — its one 1.12
tenant is the auction house, which benilla doesn't ship — but the registration table keeps
its exact shape so a later row drops in unchanged. **`UISpecialFrames` is not ours to write
into** (decision 1206): it is the table an ADDON puts its own window in, and 34 corpus call
sites do.
Rows are keyed by the frame names we actually ship. Most of those ARE the reference's own
(`GossipFrame`, `MerchantFrame`, `LootFrame`); where ours differs, the row is `Benilla*` and
its comment names the reference row it stands in for.
-->
<Script><![CDATA[
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only the
-- rows benilla ships/anticipates today (pin §1): Gossip + Merchant are live; Loot registers
-- now (pushable=7) so the coming loot window lands on an already-wired system.
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard, Petition,
-- GuildRegistrar, PetStable, Battlefield, Help, Cinematic, WorldStateScore, Minigame — and
-- the doublewide AuctionFrame) join as their windows land (audited row-by-row against the
-- bytes, decision 1507).
UIPanelWindows = {}
UIPanelWindows["GossipFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["MerchantFrame"] = { area = "left", pushable = 0 }
-- The item-text reader (decision 0572, ItemTextFrame.xml): the ref's own l.20 verbatim
-- (`UIPanelWindows["ItemTextFrame"] = { area = "left", pushable = 0 }`). The missing row
-- WAS bug B288 (decision 1507): the reader already opened through ShowUIPanel, but
-- unregistered that call is a bare Show() in no slot — so a quest giver's gossip seated
-- itself at the left slot straight over the open note, page text and greeting interleaved.
-- Registered, the two are ordinary left-slot rivals (both pushable 0): one replaces the
-- other, and the replaced reader's own OnHide sends its CloseItemText().
UIPanelWindows["ItemTextFrame"] = { area = "left", pushable = 0 }
-- The mail window (decision 0544): the reference UIParent.lua l.37 registers MailFrame
-- area="left", pushable=0. The OpenMailFrame is NOT a panel — it is a special-frame toplevel
-- (UIParent.lua l.46 UISpecialFrames), closed by ESC in ToggleGameMenu below. Our own
-- escapable toplevels stay hard-listed in that chain rather than pushed into
-- UISpecialFrames — the table exists now (1206) but it is the ADDONS' list, and mixing our
-- windows into it would make an addon's `tremove` able to break the client's own ESC.
-- area="left", pushable=0. The OpenMailFrame is NOT a panel — it is a UIChildWindow
-- (UIParent.lua l.46; 0544 misread that line as UISpecialFrames, 1507 corrects it): shown
-- through ShowUIPanel's bare-Show unregistered branch exactly as the ref shows it
-- (MailFrame.lua l.206), hidden by SetCenterFrame's child-window loop when a frame takes
-- the center seat, and closed with its parent by MailFrame's own OnHide. ESC still closes
-- it on our own hard-listed chain in ToggleGameMenu below — never via UISpecialFrames,
-- the ADDONS' list (1206), where an addon's `tremove` could break the client's own ESC.
UIPanelWindows["MailFrame"] = { area = "left", pushable = 0 }
-- The script error log (decision 1495) — OURS, with no reference row to transcribe. It is
-- registered here for exactly one reason: an unregistered frame takes ShowUIPanel's bare
-- `frame:Show()` branch (no left-slot placement, and `CloseAllWindows` never sees it), so
-- without this row the window would flip IsShown() without landing on screen and ESC would
-- not close it. `pushable = 9` puts it behind everything already registered: a diagnostic
-- must never shove a player's real window out of the slot it wanted.
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9 }
-- must never shove a player's real window out of the slot it wanted. whileDead is ours by
-- the same logic (1507): a Lua error in the death flow is exactly one you need to read.
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9, whileDead = 1 }
-- The trade window (decision 0592 P1, TradeFrame.xml): the ref's own UIParent.lua l.27
-- registration (`UIPanelWindows["TradeFrame"] = { area = "left", pushable = 1 }`). Without
-- this row ShowUIPanel takes its unregistered branch — a bare frame:Show() with no left-slot
@@ -65,26 +80,31 @@
-- what makes "ctrl-click a merchant's item" show both.
UIPanelWindows["DressUpFrame"] = { area = "left", pushable = 2 }
UIPanelWindows["QuestFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["QuestLogFrame"] = { area = "left", pushable = 0 }
-- The ref's own l.25 verbatim — whileDead carried since 1507: `UnitIsDead` is a live unit
-- binding now, so the flag decides, and checking your quest log while dead is 1.12's call.
UIPanelWindows["QuestLogFrame"] = { area = "left", pushable = 0, whileDead = 1 }
UIPanelWindows["LootFrame"] = { area = "left", pushable = 7 }
-- The character window (decision 0208 phase 1a, CharacterFrame.xml): the ref's own
-- CharacterFrame row (area "left", pushable 0) — competes for/replaces the other left-slot
-- windows exactly like Gossip/Merchant/Quest/QuestLog above.
UIPanelWindows["CharacterFrame"] = { area = "left", pushable = 0 }
-- The character window (decision 0208 phase 1a, CharacterFrame.xml): the ref's own row is
-- `{ area = "left", pushable = 2 , whileDead = 1}` (UIParent.lua l.19, spacing and all) —
-- NOT the pushable=0 this line carried from 0208 until 1507 re-read the bytes. The 2 is
-- load-bearing arbitration: an NPC session (pushable 0) opening under an open character
-- sheet slides it to the center seat — vendor left, your equipment beside it — instead of
-- replacing it; and whileDead=1 lets you look your corpse's gear over while dead.
UIPanelWindows["CharacterFrame"] = { area = "left", pushable = 2, whileDead = 1 }
-- The inspect window (decision 0631, InspectFrame.xml): the ref's own InspectFrame row
-- (`Blizzard_InspectUI.lua:4` — area "left", pushable 0), so inspecting swaps out whichever
-- left-slot window was open, exactly as the character window does.
UIPanelWindows["BenillaInspectFrame"] = { area = "left", pushable = 0 }
-- The spellbook (decision 0216 §8, SpellBookFrame.xml): the ref's own SpellBookFrame row
-- (area "left", pushable 0) — same left-slot competition as every window above.
UIPanelWindows["SpellBookFrame"] = { area = "left", pushable = 0 }
-- The talent window (decision 0304 §4, TalentFrame.xml): the ref's own TalentFrame row is
-- `{ area = "left", pushable = 6, whileDead = 1 }` — pushable 6 kept (one tier above the
-- plain NPC-session/player-state windows' 0, matching the ref exactly); `whileDead` is
-- OMITTED like every other row here (none of them model it, even though ShowUIPanel's own
-- guard already reads `info.whileDead` — ShowUIPanel's own comment: `UnitIsDead` doesn't
-- exist yet, so the guard is inert either way today).
UIPanelWindows["BenillaTalentFrame"] = { area = "left", pushable = 6 }
-- verbatim (`{ area = "left", pushable = 0, whileDead = 1 }`, UIParent.lua l.21; whileDead
-- carried since 1507) — same left-slot competition as every window above.
UIPanelWindows["SpellBookFrame"] = { area = "left", pushable = 0, whileDead = 1 }
-- The talent window (decision 0304 §4, TalentFrame.xml): the ref's own TalentFrame row
-- verbatim (`{ area = "left", pushable = 6, whileDead = 1 }`, Blizzard_TalentUI.lua:71) —
-- pushable 6 one tier above the plain NPC-session/player-state windows' 0. whileDead was
-- omitted here while `UnitIsDead` didn't exist; it is a live unit binding now, so 1507
-- carries the ref's flag on every row that has one.
UIPanelWindows["BenillaTalentFrame"] = { area = "left", pushable = 6, whileDead = 1 }
-- The social window (decision 0668) — the ref's own registration line verbatim
-- (`UIPanelWindows["FriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }`,
-- UIParent.lua l.29). whileDead: a corpse run is exactly when you want to whisper someone.
@@ -129,6 +149,19 @@
-- ShowUIPanel is what subjects it to the same rules as everything else.
UIPanelWindows["WorldMapFrame"] = { area = "full", pushable = 0, whileDead = 1 }
-- UIChildWindows — the ref's own table verbatim (UIParent.lua l.44-50; its comment:
-- windows that "rely on a parent frame to be open. If the parent closes or a pushable
-- frame overlaps them they must be hidden."). Dropped as "none exist yet" until 1507 —
-- all four ship now: the open letter (MailFrame.xml) and the guild pane's three
-- satellites (FriendsFrame.xml). The parent-close half is each parent's own OnHide
-- (MailFrame hides the letter, FriendsFrame_OnHide hides the satellites); this table
-- serves the OVERLAP half — SetCenterFrame's hide loop below — because the child popups
-- sit exactly where the center seat puts a window.
UIChildWindows = {
"OpenMailFrame", "GuildControlPopupFrame",
"GuildMemberDetailFrame", "GuildInfoFrame",
}
-- The real client stores `.left`/`.center`/`.doublewide`/`.fullscreen` as fields directly on
-- the `UIParent` frame table (UIParent.lua l.789-885). benilla has no `UIParent` frame object
-- yet (a later decision's job) — so the two slots this v1 tracks live in a private chunk-local
@@ -155,8 +188,13 @@
end
end
-- SetCenterFrame(frame, skipSetPoint) — UIParent.lua l.826-850. The `UIChildWindows` hide loop
-- (l.840-846) is dropped: no such frames exist in benilla yet (out of v1 scope).
-- SetCenterFrame(frame, skipSetPoint) — UIParent.lua l.826-850, the `UIChildWindows` hide
-- loop (l.839-846) carried since 1507: a frame ARRIVING at the center seat lands exactly
-- where the child popups sit, so they are put away first. Arriving is the ref's exact
-- trigger — MovePanelToCenter's slide re-seats by direct assignment after a
-- SetCenterFrame(nil), so a PUSHED panel does not run the loop; kept as transcribed.
-- (The ref's `for index, value in UIChildWindows do` is Lua 5.0's iterator-less
-- generic-for, which our VM cannot run — decision 1202; `ipairs` is the same walk.)
function SetCenterFrame(frame, skipSetPoint)
local oldFrame = slots.center
slots.center = frame
@@ -168,6 +206,10 @@
if not skipSetPoint then
frame:SetPoint("TOPLEFT", "UIParent", "TOPLEFT", 384, -104)
end
for _, name in ipairs(UIChildWindows) do
local childWindow = getglobal(name)
if childWindow then childWindow:Hide() end
end
end
end
@@ -254,9 +296,15 @@
-- is the point: `force` is precisely the right to throw the incumbent out instead of being
-- turned away, so each refusal and its eviction are one decision and are written as one.
local function AdmitPanel(info, force)
-- A dead player gets only the windows that asked for it. UnitIsDead doesn't exist yet
-- (pin §3) — guarded rather than assumed absent-forever.
if type(UnitIsDead) == "function" and UnitIsDead("player") and not info.whileDead then
-- A dead player gets only the windows that asked for it (l.663-666) — and hears why:
-- NotWhileDeadError is the binary's 0x48d340 (wow-re §5 cross-check, 2026-08-21 —
-- decision 1507's open item): push id 0x7e into the message catalog, whose row is
-- ERR_PLAYER_DEAD → UI_ERROR_MESSAGE, sound "NONE". Our binding (ui_errors.rs)
-- pushes the same key onto the engine's client-local error queue, which the app
-- resolves against GlobalStrings and fires as the same event. The binding is a pure
-- display trigger — this line, like the reference's, decides WHEN.
if UnitIsDead("player") and not info.whileDead then
NotWhileDeadError()
return false
end
-- l.668-675: nothing opens behind a full-screen frame. The world map is the only
+13 -4
View File
@@ -145,10 +145,14 @@ fn minimap_day_tint(ambient: [f32; 3], diffuse: [f32; 3]) -> [f32; 3] {
}
/// A `.map()` adaptor over a `md5translate.trs` hit: stream the hashed tile off-thread (like a
/// terrain tile) but decode it as a UI **sprite** (sRGB, clamp, mip 0). The `WorldArt` default
/// (gamma-space `Rgba8Unorm`, repeat) would draw the tile ~2× too bright through the UI pass and
/// bleed tile edges — the invariant `benilla-assets`' `minimap_tile_settings_reach_the_async_loader`
/// guards. Shared by the terrain and interior tile paths.
/// terrain tile) but as a **minimap tile** (`BlpVariant::MapTile`: gamma bytes with no sRGB decode
/// — the reference sampler's `GL_SKIP_DECODE_EXT` — clamp, mip 0, LINEAR). Gamma bytes move the
/// sRGB decode to AFTER the filter, so every consumer must say so or draw the tile ~2× too bright
/// through the UI pass: the outdoor quads carry
/// [`UiQuad::gamma_texel`](crate::ui_pass::UiQuad::gamma_texel), and the interior composite's
/// alpha-test arm decodes explicitly for its un-encoded target. The invariant `benilla-assets`'
/// `minimap_tile_settings_reach_the_async_loader` guards these settings reaching the async loader.
/// Shared by the terrain and interior tile paths.
fn load_tile(asset_server: &AssetServer) -> impl Fn(&str) -> Handle<Image> + '_ {
move |hash: &str| {
asset_server.load_with_settings(
@@ -672,6 +676,11 @@ fn emit_minimap(
z_key: slot.z,
texture: Some(handle.clone()),
color: [tint[0], tint[1], tint[2], slot.alpha],
// The tile is a SKIP_DECODE upload (gamma bytes — [`load_tile`]), so the
// day-night MODULATE above lands on the authored byte, exactly the reference's
// fixed-function stage. Without this the ordinary arm re-encodes an
// already-encoded byte — the outdoor minimap reads visibly too bright.
gamma_texel: true,
// No CPU clip (1463): the mask shader already zeroes everything outside
// `mask_rect` (`ui_quad.wgsl`'s `inside` test), and clipping a PANNING tile
// re-cut its quad every frame — constant positions, churning UVs — which is
+14 -24
View File
@@ -29,9 +29,10 @@ 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. On a menu opening, auto-send the ask-once `CMSG_NPC_TEXT_QUERY`
/// for its text record (served from the cache on a revisit); the record lands via [`npc_greeting`];
/// [`gossip_complete`] closes it.
/// (`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.
///
/// 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.
@@ -52,27 +53,19 @@ pub(super) fn gossip_menu(
options.len(),
quests.len()
);
gossip.npc = Some(npc);
gossip.text_id = text_id;
gossip.options = options;
gossip.quests = quests;
match gossip.cached_record(text_id).is_some() {
true => gossip.greeting = gossip.draw_greeting(text_id, npc_gender),
false => {
gossip.greeting = None;
let _ = net_commands
.0
.send(ClientCommand::NpcTextQuery { text_id, guid: npc });
}
if gossip.open_menu(npc, text_id, options, quests, npc_gender) {
let _ = net_commands
.0
.send(ClientCommand::NpcTextQuery { text_id, guid: npc });
}
}
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and draw
/// the greeting only for the OPEN menu (a late answer for a menu we already closed just seeds the
/// cache; the next open draws its own line).
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and open
/// the menu still waiting on it (a late answer for a menu we already closed or switched just
/// seeds the cache; the next open draws its own line).
///
/// The record answers a query we sent for the OPEN menu, so its NPC is the one whose gender picks
/// the column (decision 0081's ask-once flow).
/// The record answers a query we sent for the waiting menu, so its NPC is the one whose gender
/// picks the column (decision 0081's ask-once flow).
pub(super) fn npc_greeting(
text_id: u32,
blocks: Vec<NpcTextBlock>,
@@ -81,10 +74,7 @@ pub(super) fn npc_greeting(
stores: &Query<&mut ObjectStore>,
) {
let npc_gender = gossip.npc.map_or(0, |npc| npc_gender(npc, index, stores));
gossip.remember_record(text_id, blocks);
if gossip.npc.is_some() && gossip.text_id == text_id {
gossip.greeting = gossip.draw_greeting(text_id, npc_gender);
}
gossip.text_arrived(text_id, blocks, npc_gender);
}
/// `SMSG_GOSSIP_COMPLETE` ends the whole interaction (e.g. right after a quest accept), so the
+15 -7
View File
@@ -15,11 +15,13 @@
//
// Why the encode below, rather than uploading UI art as raw `Rgba8Unorm`: our BLPs load as
// `Rgba8UnormSrgb`, so the sampler hands us a LINEARIZED texel. `linear_to_srgb` puts the authored
// byte back (exact in f32: encode ∘ decode = identity), and it is the one rule that holds for every
// texture the pass samples — sRGB art, the sRGB glyph atlas, and the portrait booth's `Rgba8Unorm`
// bake (which stores linear bytes). Uploading everything raw would be cheaper and would also move
// bilinear filtering into gamma (as the reference filters), but it forces the booth to emit gamma —
// a contained follow-up, not a look change.
// byte back (exact in f32: encode ∘ decode = identity), and it is the rule for every texture the
// pass samples — sRGB art, the sRGB glyph atlas, and the portrait booth's `Rgba8Unorm` bake (which
// stores linear bytes) — EXCEPT a texture that says otherwise: a SKIP_DECODE upload (`gamma_texel`
// below — the minimap tiles) already holds the authored byte, and encoding it again is the
// double-gamma that washed the outdoor minimap bright. Uploading everything raw would be cheaper
// and would also move bilinear filtering into gamma (as the reference filters), but it forces the
// booth to emit gamma — a contained follow-up, not a look change.
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
@@ -91,6 +93,11 @@
// so folding the mask ramp into the test would saw the disc's soft rim into a hard, undersized
// circle.
@group(2) @binding(9) var<uniform> alpha_ref: f32;
// The bound texture was uploaded UNDECODED — `BlpVariant::MapTile`'s `GL_SKIP_DECODE_EXT`, the
// minimap tiles — so the sampler hands back the authored GAMMA byte itself and the ordinary arm's
// `linear_to_srgb` has no decode to undo: the texel passes through as-is. The alpha-test arm
// ignores this flag — its target is the un-encoded composite, so it decodes explicitly instead.
@group(2) @binding(10) var<uniform> gamma_texel: u32;
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
@@ -129,8 +136,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
#endif
// Back to the client's byte space, then tint there: `UiQuad.color` is already a client-space
// sRGB value (FrameXML's `<Color>`, `|cff…`, quality colors), so this multiply IS the FFP's
// gamma-space `tint × texel`.
let texel = linear_to_srgb(t.rgb);
// gamma-space `tint × texel`. A SKIP_DECODE texture (`gamma_texel`) is already IN byte space —
// re-encoding it is the double-gamma that washed the outdoor minimap bright.
let texel = select(linear_to_srgb(t.rgb), t.rgb, gamma_texel != 0u);
// The desaturated arm REPLACES the modulate — see the `desaturate` binding above. `c.rgb` is
// deliberately unread here; only `c.a` carries into the alpha below, as it does on both paths.
var rgb = texel * c.rgb;
+233 -15
View File
@@ -5,11 +5,19 @@
//! `CMSG_NPC_TEXT_QUERY` for the greeting; `SMSG_NPC_TEXT_UPDATE` → the greeting; `SMSG_GOSSIP_COMPLETE`
//! → cleared). Each frame [`feed_gossip`] turns that state into a [`GossipMenu`] snapshot, pushes it
//! into the VM ([`benilla_ui::script::UiScript::set_gossip`]), and fires `GOSSIP_SHOW` on open (or a
//! content change — the async greeting landing) / `GOSSIP_CLOSED` on clear. [`drain_gossip`] pulls the
//! Lua intents back out: `SelectGossipOption` → [`ClientCommand::GossipSelectOption`] (mapped through
//! the wire option `index`; coded options are 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`).
//! content change while open — the NPC's name resolving) / `GOSSIP_CLOSED` on clear.
//! [`drain_gossip`] pulls the Lua intents back out: `SelectGossipOption` →
//! [`ClientCommand::GossipSelectOption`] (mapped through the wire option `index`; coded options are
//! 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.
use std::collections::HashMap;
@@ -40,8 +48,10 @@ pub(crate) struct GossipState {
pub(crate) npc: Option<u64>,
/// 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 (or when the
/// record names no line for this NPC — the reference's "Missing gossip text!" path).
/// 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.
pub(crate) greeting: Option<String>,
/// The selectable option rows (wire `GossipOption`: `index` echoed on select, `icon`, `coded`,
/// `message`).
@@ -71,6 +81,67 @@ impl GossipState {
let blocks = self.cached_record(text_id)?;
select_greeting(blocks, npc_gender, greeting_roll()).map(str::to_string)
}
/// 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.
pub(crate) fn open_menu(
&mut self,
npc: u64,
text_id: u32,
options: Vec<GossipOption>,
quests: Vec<(u32, u32, String)>,
npc_gender: u8,
) -> bool {
self.npc = Some(npc);
self.text_id = text_id;
self.options = options;
self.quests = quests;
self.greeting = None;
if text_id == 0 {
return false;
}
match self.cached_record(text_id).is_some() {
true => {
self.resolve_greeting(npc_gender);
false
}
false => true,
}
}
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`): seed the ask-once cache, and if it answers
/// the menu still waiting on it, open that menu now. A late answer for a menu we already
/// closed or switched away from just seeds the cache; an answer for a menu already open does
/// not re-roll its greeting (the reference's cache callback only re-enters for the pending
/// query it was registered by).
pub(crate) fn text_arrived(&mut self, text_id: u32, blocks: Vec<NpcTextBlock>, npc_gender: u8) {
self.remember_record(text_id, blocks);
if self.npc.is_some() && self.text_id == text_id && self.greeting.is_none() {
self.resolve_greeting(npc_gender);
}
}
/// Draw the open menu's greeting from its (present) record — or, when the record names no
/// line for this NPC's gender column, end the interaction: the reference's `missing` path
/// logs "Missing gossip text!" and fires `GOSSIP_CLOSED` instead of ever opening the frame
/// (wow-re `gossip-npctext-law.md` §2). Ours never opened either, so the clear is the whole
/// close.
fn resolve_greeting(&mut self, npc_gender: u8) {
match self.draw_greeting(self.text_id, npc_gender) {
Some(line) => self.greeting = Some(line),
None => {
debug!(
"ui_gossip: missing gossip text (id {}) — ending the interaction",
self.text_id
);
self.clear();
}
}
}
}
/// The reference's uniform float in `[1.0, 2.0)` for the greeting draw: it stuffs PRNG bits straight
@@ -183,11 +254,13 @@ const GOSSIP_ICON_TYPES: [&str; 14] = [
"gossip",
];
/// Build the Lua-facing snapshot from [`GossipState`] — `None` when no menu is open.
/// 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.
fn snapshot(state: &GossipState) -> Option<GossipMenu> {
state.npc?;
Some(GossipMenu {
greeting: state.greeting.clone(),
greeting: state.greeting.clone()?,
// Quest rows riding the gossip packet (decision 0088): split active-vs-available by the same
// predicate the quest window's greeting panel uses — the WIRE ICON. The reference runs the
// identical `{3,4}` test here, just lazily (`0x4e2430`/`0x4e2580`, behind
@@ -235,7 +308,7 @@ fn feed_gossip(
let last_npc = last_npc.get(&script);
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().and_then(|m| m.greeting.as_mut()) {
if let Some(greeting) = fresh.as_mut().map(|m| &mut m.greeting) {
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
*greeting = crate::npc_text::substitute(
greeting,
@@ -255,8 +328,12 @@ fn feed_gossip(
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).
let switched = npc_switched(*last_npc, state.npc);
// 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);
if fresh == *last && !name_changed && !switched {
return;
}
@@ -274,13 +351,21 @@ fn feed_gossip(
// 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![]),
(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();
}
}
(None, None) => {}
}
}
*last = fresh;
*last_name = npc_name;
*last_npc = state.npc;
*last_npc = shown_npc;
}
/// Drain the Lua intents: a selected option → `CMSG_GOSSIP_SELECT_OPTION` (mapped to the wire option
@@ -296,6 +381,15 @@ 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() {
debug!(
"ui_gossip: SelectGossipOption({pos}) while the text query is in flight — refused"
);
continue;
}
// Lua position is 1-based; resolve it to the option's wire `index`.
match pos
.checked_sub(1)
@@ -316,6 +410,14 @@ fn drain_gossip(
// QUERY_QUEST (available → look at/accept) or COMPLETE_QUEST (active → turn-in progress).
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() {
debug!(
"ui_gossip: SelectGossipQuest({pos}) while the text query is in flight — refused"
);
continue;
}
match pos
.checked_sub(1)
.and_then(|i| state.quests.get(i as usize))
@@ -363,11 +465,127 @@ mod tests {
message: "Browse".into(),
}];
let menu = snapshot(&state).expect("open");
assert_eq!(menu.greeting.as_deref(), Some("Hello"));
assert_eq!(menu.greeting, "Hello");
assert_eq!(menu.options.len(), 1);
assert_eq!(menu.options[0].icon_type, "vendor");
}
/// B292's hold: a first visit (record not cached) keeps the menu CLOSED for the query round
/// trip — options never render over an empty page — and the text arriving opens it complete.
/// The reference's law, from the bytes (wow-re `gossip-npctext-law.md` §4): the greeting write
/// and `GOSSIP_SHOW` are adjacent and unconditional on one success path; every other exit
/// fires no event.
#[test]
fn first_visit_holds_the_menu_until_the_text_answers() {
let mut state = GossipState::default();
let options = vec![GossipOption {
index: 0,
icon: 9,
coded: false,
message: "I wish to join the battle!".into(),
}];
let wants_query = state.open_menu(0x42, 50, options, Vec::new(), 0);
assert!(wants_query, "first visit asks for the text");
assert!(
snapshot(&state).is_none(),
"menu held closed while the query is in flight"
);
state.text_arrived(
50,
vec![NpcTextBlock {
probability: 0.0,
male: "The Alliance needs you!".into(),
female: String::new(),
}],
0,
);
let menu = snapshot(&state).expect("text landed — the menu opens now, complete");
assert_eq!(menu.greeting, "The Alliance needs you!");
assert_eq!(menu.options.len(), 1, "options open WITH the greeting");
}
/// 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]
fn revisit_opens_immediately_from_the_cache() {
let mut state = GossipState::default();
state.remember_record(
50,
vec![NpcTextBlock {
probability: 0.0,
male: "Back again?".into(),
female: String::new(),
}],
);
let wants_query = state.open_menu(0x42, 50, Vec::new(), Vec::new(), 0);
assert!(!wants_query, "cached — no re-query");
assert_eq!(
snapshot(&state).expect("open at once").greeting,
"Back again?"
);
}
/// The reference's `missing` path (`0x4e216e`): a record that names no line for this NPC's
/// gender column ends the interaction — the frame never opens with a blank page, and never
/// parks half-open either. And `text_id == 0` never queries at all (`DBCache::Get` refuses
/// id 0): the menu stays latched closed.
#[test]
fn missing_text_ends_the_interaction_and_id_zero_never_queries() {
// Missing: the record arrives but its male column is empty → no line → session over.
let mut state = GossipState::default();
assert!(state.open_menu(0x42, 60, Vec::new(), Vec::new(), 0));
state.text_arrived(
60,
vec![NpcTextBlock {
probability: 0.0,
male: String::new(),
female: "Sister.".into(),
}],
0,
);
assert_eq!(state.npc, None, "missing gossip text — interaction ended");
assert!(snapshot(&state).is_none());
// Id 0: no query wanted, and the menu never opens.
let mut state = GossipState::default();
assert!(!state.open_menu(0x42, 0, Vec::new(), Vec::new(), 0));
assert!(snapshot(&state).is_none());
}
/// A late answer for a menu we closed or switched away from only seeds the cache; an answer
/// for a menu already open does not re-roll its greeting.
#[test]
fn late_or_duplicate_answers_only_seed_the_cache() {
let blocks = || {
vec![NpcTextBlock {
probability: 0.0,
male: "Hail.".into(),
female: String::new(),
}]
};
// Switched: the waiting menu is for id 70; id 50's late answer must not open it.
let mut state = GossipState::default();
assert!(state.open_menu(0x42, 70, Vec::new(), Vec::new(), 0));
state.text_arrived(50, blocks(), 0);
assert!(snapshot(&state).is_none(), "still waiting on id 70");
assert!(
state.cached_record(50).is_some(),
"the answer seeded the cache"
);
// Already open: a duplicate answer leaves the drawn line alone.
state.text_arrived(70, blocks(), 0);
assert_eq!(snapshot(&state).expect("open").greeting, "Hail.");
state.greeting = Some("The drawn line".into());
state.text_arrived(70, blocks(), 0);
assert_eq!(
state.greeting.as_deref(),
Some("The drawn line"),
"no re-roll"
);
}
#[test]
fn ask_once_text_cache_survives_clear() {
let mut state = GossipState::default();
+110
View File
@@ -1136,9 +1136,45 @@ fn diff_and_push(
.filter(|b| fresh.get(b) != memory.pushed.get(b))
.collect();
if !changed.is_empty() {
// **The spent-ammo signal** (decision 1509). The reference registers a field mirror on
// `ITEM_FIELD_STACK_COUNT` for TYPEID ITEM (`ClntObjMgrSetTypeMirrorHandler 0x468070` at
// `0x5d9360`, handler `0x5d9400`); for any item the active player owns, a stack-count
// write fires **ITEM_LOCK_CHANGED first**, ahead of the `BAG_UPDATE` the same handler
// emits further down. Nothing else in the image reaches that event from a server update —
// its other four fire sites are all local lock/unlock — so it is the ONLY per-shot signal
// an addon can detect a spent arrow with, and every auto-shot timer is built on it.
//
// Computed before `memory.pushed` is replaced at the bottom of this block.
let restacked = {
let empty = HashMap::new();
let mut v: Vec<(i64, u32)> = Vec::new();
for &bag in &changed {
let now = fresh.get(&bag).map_or(&empty, |c| &c.slots);
let was = memory.pushed.get(&bag).map_or(&empty, |c| &c.slots);
for (&slot, n) in now {
// The SAME item, restacked. A slot whose entry changed is a create or a swap,
// not a field write on one item, and it does not take this path.
if was.get(&slot).is_some_and(|w| {
w.item_id != 0 && w.item_id == n.item_id && w.count != n.count
}) {
v.push((bag, slot));
}
}
}
// Map order is not an order, and an event stream has to be reproducible.
v.sort_unstable();
v
};
for &bag in &changed {
script.set_container(bag, fresh.get(&bag).cloned());
}
// Ahead of BAG_UPDATE below, which is the reference handler's own order.
for (bag, slot) in restacked {
script.fire_event(
"ITEM_LOCK_CHANGED",
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
);
}
// Name the bags, not just the count: "3 changed" can't tell you WHICH container moved, and
// the negative ids (−1 bank, −2 keyring) are exactly the ones you go looking for.
debug!(
@@ -1239,6 +1275,80 @@ mod tests {
assert_eq!(s.eval::<i64>("return BAG_EVENTS").unwrap(), 2);
}
/// **The spent-ammo signal** (decision 1509, B267's second half). A stack ticking down must
/// fire `ITEM_LOCK_CHANGED` for that slot, **before** the `BAG_UPDATE` for its bag — the
/// reference's `ITEM_FIELD_STACK_COUNT` mirror handler's own order.
///
/// This is not cosmetic ordering. Quiver's auto-shot timer has no other way to learn a shot
/// fired: it starts the reload drain from this event, and without it the bar fills once and
/// sits at 100% forever (the director's report). Every vanilla shot timer works this way.
///
/// The negative half matters as much: a slot whose ENTRY changed is a swap or a create, not a
/// field write on one item, and must NOT fire it — over-firing would make the addon count
/// shots that never happened.
#[test]
fn a_stack_ticking_down_fires_item_lock_changed_before_bag_update() {
use benilla_ui::script::ContainerSlot;
let mut s = UiScript::new().unwrap();
s.run(
"ORDER = {} \
local f = CreateFrame('Frame') \
f:RegisterEvent('ITEM_LOCK_CHANGED') \
f:RegisterEvent('BAG_UPDATE') \
f:SetScript('OnEvent', function() \
table.insert(ORDER, event .. ':' .. tostring(arg1) .. ',' .. tostring(arg2)) \
end)",
)
.unwrap();
let arrows = |count: u32| ContainerSlot {
item_id: 2512, // Rough Arrow
count,
..Default::default()
};
let bag = |s: ContainerSlot| {
HashMap::from([(
0,
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: HashMap::from([(1, s)]),
},
)])
};
let mut memory = FeedMemory::default();
// The quiver arrives full — a create, not a restack. No lock event.
apply_container_source(&mut s, &mut memory, Some(bag(arrows(200))), Vec::new());
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"BAG_UPDATE:0,nil",
"a slot appearing is a create, not a stack-count field write"
);
// An arrow leaves the quiver.
s.run("ORDER = {}").unwrap();
apply_container_source(&mut s, &mut memory, Some(bag(arrows(199))), Vec::new());
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"ITEM_LOCK_CHANGED:0,1 BAG_UPDATE:0,nil",
"the shot signal fires for (bag 0, slot 1) and precedes BAG_UPDATE"
);
// A DIFFERENT item in the same slot: a swap. The count differs too, and it must still
// not fire — otherwise an addon counts a shot every time you rearrange your bags.
s.run("ORDER = {}").unwrap();
let mut other = arrows(20);
other.item_id = 3033; // Razor Arrow
apply_container_source(&mut s, &mut memory, Some(bag(other)), Vec::new());
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"BAG_UPDATE:0,nil",
"a changed entry is a swap, not a stack-count write"
);
}
fn slot(spell_id: u32, charges: i32) -> ItemSpellEntry {
ItemSpellEntry {
index: 0,
+35 -24
View File
@@ -183,6 +183,17 @@ pub(crate) struct UiQuad {
/// Splits the run like `additive`/`circular`; a booth target is its own texture anyway, so no
/// batching is lost.
pub premultiplied: bool,
/// The sampled texture holds the client's **gamma bytes, undecoded** — a SKIP_DECODE upload
/// (`BlpVariant::MapTile`, the minimap tiles: the reference's tile sampler carries
/// `GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`, so the hardware filters authored bytes).
/// The ordinary arm's `linear_to_srgb` exists to undo the sampler's sRGB decode; on a texture
/// whose sampler did no decode it ENCODES A SECOND TIME, which is how the outdoor minimap
/// washed bright when MapTile moved off `Rgba8UnormSrgb` and only the alpha-test arm learned
/// the new contract. With this set the texel passes through as the authored byte and the
/// vertex-colour multiply lands on it directly — the fixed-function MODULATE, byte for byte.
/// The alpha-test arm ignores the flag: it decodes explicitly for the un-encoded composite
/// target. Splits the run like `additive`/`circular`.
pub gamma_texel: bool,
/// **Alpha TEST** instead of alpha blend, at this reference value on the client's
/// `texel.a × vertexColour.a` — `Some(224.0 / 255.0)` is the WMO-interior minimap tile draw and
/// nothing else so far. A passing fragment writes FULLY OPAQUE (the screen mask still cuts it);
@@ -242,6 +253,7 @@ impl Default for UiQuad {
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
clip: None,
rotation: 0.0,
@@ -362,6 +374,10 @@ pub(crate) struct UiQuadMaterial {
/// Alpha-TEST reference on `texel.a × colour.a`; `<= 0` disables — see [`UiQuad::alpha_test`].
#[uniform(9)]
alpha_ref: f32,
/// The texel is already the client's gamma byte (a SKIP_DECODE upload — the minimap tiles), so
/// the ordinary arm skips its `linear_to_srgb` re-encode — see [`UiQuad::gamma_texel`].
#[uniform(10)]
gamma_texel: u32,
/// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the
/// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`]
/// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking.
@@ -392,6 +408,9 @@ impl UiQuadMaterial {
desaturate: 0,
premultiplied: 0,
alpha_ref,
// The alpha-test arm never reads this: it decodes explicitly (its target is the
// un-encoded composite), so the tile's SKIP_DECODE contract is already honoured there.
gamma_texel: 0,
mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
mask: None,
}
@@ -634,6 +653,7 @@ struct Run {
circular: bool,
desaturated: bool,
premultiplied: bool,
gamma_texel: bool,
alpha_test: Option<f32>,
mask: Option<UiQuadMask>,
positions: Vec<[f32; 3]>,
@@ -643,23 +663,18 @@ struct Run {
}
impl Run {
fn new(
texture: Handle<Image>,
additive: bool,
circular: bool,
desaturated: bool,
premultiplied: bool,
alpha_test: Option<f32>,
mask: Option<UiQuadMask>,
) -> Self {
/// A fresh run carrying `q`'s material identity (every flag that splits a run) under the
/// already-resolved `texture` (the quad's own, or the shared white fallback).
fn new(texture: Handle<Image>, q: &UiQuad) -> Self {
Self {
texture,
additive,
circular,
desaturated,
premultiplied,
alpha_test,
mask,
additive: q.additive,
circular: q.circular,
desaturated: q.desaturated,
premultiplied: q.premultiplied,
gamma_texel: q.gamma_texel,
alpha_test: q.alpha_test,
mask: q.mask.clone(),
positions: Vec::new(),
uvs: Vec::new(),
colors: Vec::new(),
@@ -818,6 +833,7 @@ type MatKey = (
bool,
bool,
bool,
bool,
u32,
Option<AssetId<Image>>,
[u32; 4],
@@ -1004,19 +1020,12 @@ fn rebuild_ui_mesh(
&& r.circular == q.circular
&& r.desaturated == q.desaturated
&& r.premultiplied == q.premultiplied
&& r.gamma_texel == q.gamma_texel
&& r.alpha_test == q.alpha_test
&& r.mask == q.mask
});
if !same_run {
runs.push(Run::new(
texture,
q.additive,
q.circular,
q.desaturated,
q.premultiplied,
q.alpha_test,
q.mask.clone(),
));
runs.push(Run::new(texture, q));
}
let run = runs.last_mut().unwrap();
match (q.corners, plain) {
@@ -1083,6 +1092,7 @@ fn rebuild_ui_mesh(
run.circular,
run.desaturated,
run.premultiplied,
run.gamma_texel,
alpha_ref.to_bits(),
mask.as_ref().map(bevy::asset::Handle::id),
mask_rect.to_array().map(f32::to_bits),
@@ -1204,6 +1214,7 @@ fn rebuild_ui_mesh(
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
alpha_ref,
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask,
})
@@ -160,6 +160,7 @@ pub(super) fn emit(
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
clip: text_clip,
rotation: 0.0,
@@ -345,6 +346,7 @@ pub(super) fn emit(
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
clip: text_clip,
rotation: 0.0,
@@ -0,0 +1,119 @@
//! The fade kit's driver (`UIFrameFade` / `BenillaFadeDriver`, UiPanels.xml) — the park/wake
//! lifecycle that keeps the per-frame fade walk off the tick while nothing is fading. Split from
//! `panel_tests.rs` (1507's round): the slot manager and the fade kit share a source file, not a
//! concern.
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error — the same
/// per-file loader every `ui_script` test file carries (these tests only ever need
/// `UiPanels.xml`, where the fade kit lives).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// **The idle fade driver parks itself** (decision 1396's class, the audit's driver-hide item):
/// with `FADEFRAMES` empty there is nothing to walk, so the driver's OnUpdate hides its own frame
/// — and the engine dispatches OnUpdate only to visible frames (`tick.rs`), which stops the
/// per-frame walk cold. The probe wraps `UIFrameFadeUpdate` in a counter BEFORE the first tick:
/// ten idle ticks later it has never run, and the driver is hidden.
#[test]
fn an_idle_fade_driver_parks_itself_off_the_tick() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(
"BENILLA_TEST_FADE_TICKS = 0\n\
local real = UIFrameFadeUpdate\n\
function UIFrameFadeUpdate(elapsed)\n\
BENILLA_TEST_FADE_TICKS = BENILLA_TEST_FADE_TICKS + 1\n\
real(elapsed)\n\
end",
)
.unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"an empty FADEFRAMES parks the driver"
);
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_FADE_TICKS").unwrap(),
0,
"a parked driver never runs the fade walk"
);
}
/// The control for the guard above: the gate must not cost the fades it gates. `UIFrameFade` (the
/// kit's single insertion point — `UIFrameFadeIn`/`Out` both land there) wakes the driver beside
/// its `table.insert`; the fade then ramps on real ticks, completes, and the driver re-parks on
/// the first empty tick after.
#[test]
fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
.unwrap();
for _ in 0..2 {
s.tick(0.016); // settle: the driver parks
s.resolve();
}
assert!(!s
.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap());
s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
assert!(
s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"starting a fade wakes the driver"
);
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
0.0,
"the fade armed at its IN startAlpha"
);
s.tick(0.5);
s.resolve();
let mid = s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap();
assert!(
(mid - 0.5).abs() < 1e-3,
"half the timeToFade in, half the ramp: {mid}"
);
s.tick(0.6); // past timeToFade: the fade completes and leaves the list
s.resolve();
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
1.0,
"the fade completes at its endAlpha"
);
s.tick(0.016); // the first empty tick re-parks the driver
s.resolve();
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"the driver re-parks once the list is empty"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+3
View File
@@ -698,6 +698,9 @@ mod perf_bar_tests;
#[cfg(test)]
mod panel_tests;
#[cfg(test)]
mod fade_tests;
#[cfg(test)]
mod merchant_tests;
+144 -83
View File
@@ -80,7 +80,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
// The app's feed: a two-option menu (a vendor option + a coded petition option).
s.set_gossip(Some(GossipMenu {
greeting: Some("Greetings, traveler. How may I help you?".into()),
greeting: "Greetings, traveler. How may I help you?".into(),
quests: Vec::new(),
options: vec![
GossipOptionView {
@@ -234,7 +234,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
load_xml(&s, "GossipFrame.xml");
s.set_gossip(Some(GossipMenu {
greeting: Some("A word, traveler.".into()),
greeting: "A word, traveler.".into(),
quests: vec![
GossipQuestRow {
title: "Report to Goldshire".into(),
@@ -356,7 +356,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: Some("Make your choice!".into()),
greeting: "Make your choice!".into(),
quests: Vec::new(),
options: vec![
long(
@@ -461,7 +461,7 @@ fn gossip_show_hide_plays_open_and_close_kits() {
);
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: Vec::new(),
}));
@@ -493,7 +493,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
load_xml(&s, "MerchantFrame.xml");
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: vec![GossipOptionView {
label: "Let me browse your goods.".into(),
@@ -581,7 +581,7 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
// Gossip opens over it → SetLeftFrame hides the merchant → merchant OnHide → CloseMerchant().
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: Vec::new(),
}));
@@ -715,7 +715,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
// The gossip menu is open on the banker (its bank option showing).
s.set_gossip(Some(GossipMenu {
greeting: Some("Welcome to the bank of Ironforge!".into()),
greeting: "Welcome to the bank of Ironforge!".into(),
quests: Vec::new(),
options: vec![GossipOptionView {
label: "I would like to check my deposit box.".into(),
@@ -789,7 +789,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: Some("Make your choice!".into()),
greeting: "Make your choice!".into(),
quests: Vec::new(),
options: (1..=8).map(long).collect(),
}));
@@ -956,7 +956,7 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
// when BOTH are pushable=0 (l.728-731). Getting this wrong for an addon means either losing
// its window or leaving two panels on top of each other.
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: Vec::new(),
}));
@@ -980,98 +980,159 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The fade kit's driver (UIFrameFade / BenillaFadeDriver) ──────────────────────────────────
// ── the registry pinned to the bytes (decision 1507) ──────────────────────────────────────────
/// **The idle fade driver parks itself** (decision 1396's class, the audit's driver-hide item):
/// with `FADEFRAMES` empty there is nothing to walk, so the driver's OnUpdate hides its own frame
/// — and the engine dispatches OnUpdate only to visible frames (`tick.rs`), which stops the
/// per-frame walk cold. The probe wraps `UIFrameFadeUpdate` in a counter BEFORE the first tick:
/// ten idle ticks later it has never run, and the driver is hidden.
/// **The 1507 rows read exactly as the reference wrote them.** The registry is DATA — a different
/// number is a different window order — and 1507 exists because one row drifted unnoticed for
/// months (CharacterFrame carried pushable=0 labelled as "the ref's own row"; the bytes say
/// `pushable = 2, whileDead = 1`, UIParent.lua l.19) while another was simply missing
/// (ItemTextFrame, l.20 — bug B288: the reader opened through ShowUIPanel's unregistered bare-Show
/// branch, so gossip seated itself straight over the open note). A drive-by edit re-introducing
/// either now fails a named test instead of waiting for a player with a Verdant Note.
#[test]
fn an_idle_fade_driver_parks_itself_off_the_tick() {
fn the_1507_registry_rows_match_the_reference_bytes() {
let s = UiScript::new().unwrap();
load_xml(&s, "UiPanels.xml");
for probe in [
// ItemTextFrame — UIParent.lua l.20 (the B288 row).
"UIPanelWindows['ItemTextFrame'].area == 'left'",
"UIPanelWindows['ItemTextFrame'].pushable == 0",
"UIPanelWindows['ItemTextFrame'].whileDead == nil",
// CharacterFrame — UIParent.lua l.19: pushable 2 (slides beside an NPC session), not 0.
"UIPanelWindows['CharacterFrame'].pushable == 2",
"UIPanelWindows['CharacterFrame'].whileDead == 1",
// The whileDead flags the ref authors and 1507 carried (l.21, l.25, Blizzard_TalentUI:71).
"UIPanelWindows['SpellBookFrame'].whileDead == 1",
"UIPanelWindows['QuestLogFrame'].whileDead == 1",
"UIPanelWindows['BenillaTalentFrame'].whileDead == 1",
// UIChildWindows — UIParent.lua l.44-50 verbatim, all four shipped.
"table.getn(UIChildWindows) == 4",
"UIChildWindows[1] == 'OpenMailFrame'",
"UIChildWindows[2] == 'GuildControlPopupFrame'",
"UIChildWindows[3] == 'GuildMemberDetailFrame'",
"UIChildWindows[4] == 'GuildInfoFrame'",
] {
assert!(
s.eval::<bool>(&format!("return {probe}")).unwrap(),
"registry drifted from the bytes: {probe}"
);
}
}
/// **A dead player opens only the windows whose row asked for it** — ShowUIPanel's own guard
/// (UIParent.lua l.663-666: `UnitIsDead("player") and not info.whileDead` refuses the open),
/// live now that `UnitIsDead` is a real unit binding. The refused window must not show AND must
/// not take a slot; a `whileDead = 1` row (the quest log, l.25) opens exactly as alive.
#[test]
fn a_dead_player_opens_only_whiledead_windows() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
max_health: 100,
health: 0,
dead: true,
..Default::default()
}),
);
// Stand-ins for two shipped rows: GossipFrame (no whileDead) and QuestLogFrame (whileDead=1).
// Bare frames are enough — the guard runs before any seat is chosen.
s.run(
"BENILLA_TEST_FADE_TICKS = 0\n\
local real = UIFrameFadeUpdate\n\
function UIFrameFadeUpdate(elapsed)\n\
BENILLA_TEST_FADE_TICKS = BENILLA_TEST_FADE_TICKS + 1\n\
real(elapsed)\n\
end",
r#"local g = CreateFrame("Frame", "GossipFrame") g:SetSize(50, 50) g:Hide()
local q = CreateFrame("Frame", "QuestLogFrame") q:SetSize(50, 50) q:Hide()
ShowUIPanel(GossipFrame)"#,
)
.unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"an empty FADEFRAMES parks the driver"
!s.eval::<bool>("return GossipFrame:IsShown()").unwrap(),
"a row without whileDead is refused while dead"
);
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the refused window took no slot"
);
// ...and the refusal is heard: NotWhileDeadError (the binary's 0x48d340 — push 0x7e, wow-re
// cross-checked) queued the catalog row's key for the app to resolve and toast.
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_FADE_TICKS").unwrap(),
0,
"a parked driver never runs the fade walk"
s.take_ui_errors(),
vec!["ERR_PLAYER_DEAD"],
"the dead refusal queued its error key"
);
s.run("ShowUIPanel(QuestLogFrame)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return QuestLogFrame:IsShown()").unwrap(),
"a whileDead = 1 row opens exactly as alive"
);
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == 'QuestLogFrame'")
.unwrap(),
"and it seats normally at the left slot"
);
assert!(
s.take_ui_errors().is_empty(),
"an admitted open raises no error"
);
}
/// The control for the guard above: the gate must not cost the fades it gates. `UIFrameFade` (the
/// kit's single insertion point — `UIFrameFadeIn`/`Out` both land there) wakes the driver beside
/// its `table.insert`; the fade then ramps on real ticks, completes, and the driver re-parks on
/// the first empty tick after.
/// **A frame ARRIVING at the center seat puts the child windows away; a frame PUSHED there does
/// not** — SetCenterFrame's `UIChildWindows` hide loop (UIParent.lua l.839-846), carried since
/// 1507, and the ref's exact asymmetry: MovePanelToCenter re-seats by direct assignment after a
/// `SetCenterFrame(nil)`, so the slide never runs the loop. The open letter (OpenMailFrame) is
/// the shipped tenant; a bare stand-in proves the slot math without the mail window's whole
/// dependency chain.
#[test]
fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
fn a_frame_arriving_at_center_puts_the_child_windows_away() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
.unwrap();
for _ in 0..2 {
s.tick(0.016); // settle: the driver parks
s.resolve();
}
assert!(!s
.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap());
s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
assert!(
s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"starting a fade wakes the driver"
);
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
0.0,
"the fade armed at its IN startAlpha"
);
s.tick(0.5);
s.resolve();
let mid = s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap();
assert!(
(mid - 0.5).abs() < 1e-3,
"half the timeToFade in, half the ramp: {mid}"
);
s.tick(0.6); // past timeToFade: the fade completes and leaves the list
s.resolve();
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
1.0,
"the fade completes at its endAlpha"
);
s.tick(0.016); // the first empty tick re-parks the driver
s.resolve();
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"the driver re-parks once the list is empty"
);
// The letter is open; two shipped-row stand-ins take the seats: MerchantFrame (pushable 0)
// holds left, TradeFrame (pushable 1) then ARRIVES at center (UIParent.lua l.734-741's
// else-arm: the incumbent outranks nobody, the newcomer settles at center).
s.run(
r#"local m = CreateFrame("Frame", "OpenMailFrame") m:SetSize(50, 50)
local a = CreateFrame("Frame", "MerchantFrame") a:SetSize(50, 50) a:Hide()
local b = CreateFrame("Frame", "TradeFrame") b:SetSize(50, 50) b:Hide()
ShowUIPanel(MerchantFrame)
ShowUIPanel(TradeFrame)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == 'TradeFrame'")
.unwrap(),
"the pushable=1 newcomer arrived at the center seat"
);
assert!(
!s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"the arriving center frame hid the open letter (the UIChildWindows loop)"
);
// The asymmetry: seats cleared, letter re-shown, LootFrame (pushable 7) holds left and a
// pushable=0 window shoves it — MovePanelToCenter SLIDES loot across, and the letter stays.
s.run(
r#"HideUIPanel(TradeFrame) HideUIPanel(MerchantFrame)
OpenMailFrame:Show()
local l = CreateFrame("Frame", "LootFrame") l:SetSize(50, 50) l:Hide()
ShowUIPanel(LootFrame)
ShowUIPanel(MerchantFrame)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == 'LootFrame'")
.unwrap(),
"loot slid to center (MovePanelToCenter), merchant took left"
);
assert!(
s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"a PUSHED frame does not run the child-window loop — the ref's exact trigger"
);
}
@@ -270,3 +270,301 @@ fn quiver_survives_a_hunter_session_start_without_raising() {
"Quiver raised at session start: {raised:#?}"
);
}
// ───────────────────────────────────────────────────────────────────────────────────────────────
// The Auto Shot Timer's state machine — why the bar "just stays full".
// ───────────────────────────────────────────────────────────────────────────────────────────────
/// A ranged weapon on the character sheet, so `UnitRangedDamage("player")` answers a real speed.
///
/// 2.8s is a vanilla hunter bow. The addon computes `reloadTime = speed - 0.5`, so this is what
/// makes the reload phase measurable rather than a divide by zero.
fn seat_a_bow(s: &mut UiScript) {
use benilla_ui::script::UnitCombatStats;
s.set_player_combat_stats(Some(UnitCombatStats {
ranged_attack_time_ms: 2800,
ranged_min_damage: 31.0,
ranged_max_damage: 47.0,
damage_percent: 1.0,
..Default::default()
}));
}
/// The event burst a real session start fires, in order.
fn start_session(s: &mut UiScript) {
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
}
/// The addon's own state machine, read through the three globals it publishes for macros.
/// Reading these beats measuring the bar's pixels: they ARE what the bar draws from.
fn shot_state(s: &mut UiScript) -> (bool, bool, f64, f64) {
let mid = s.eval::<bool>("return Quiver.PredMidShot()").unwrap();
let (reloading, reload_left) = s
.eval::<(bool, f64)>("return Quiver.GetSecondsRemainingReload()")
.unwrap();
let (_, shoot_left) = s
.eval::<(bool, f64)>("return Quiver.GetSecondsRemainingShoot()")
.unwrap();
(mid, reloading, reload_left, shoot_left)
}
/// **The reported symptom, reproduced.** *"the shot timer doesn't seem to work properly when
/// standing still and shooting, it just stays full."*
///
/// Quiver's ONLY detector for "an auto shot actually fired" is `ITEM_LOCK_CHANGED` — in the real
/// client, spending an arrow toggles the ammo slot's lock, and the addon's own comment says so:
/// *"Inventory event, such as using ammo or drinking a potion. This is how we detect auto shots."*
///
/// benilla fires `ITEM_LOCK_CHANGED` only from bag / cursor / mail / loot paths. Nothing fires it
/// for ammo spent on a ranged attack. So the addon starts its 0.5s aim, saturates it, and waits
/// forever for a shot it is never told about — which is a bar pinned at 100%.
#[test]
fn auto_shot_bar_saturates_when_no_ammo_lock_event_ever_arrives() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
// The player presses the ranged-attack key, then stands still and shoots for two seconds.
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
for _ in 0..20 {
s.tick(0.1);
}
let (mid, reloading, _, shoot_left) = shot_state(&mut s);
assert!(mid, "the addon does believe it is shooting");
assert!(
!reloading,
"THE BUG: two seconds into a 2.8s weapon cycle and the reload phase never began, \
because nothing told the addon a shot went off"
);
assert!(
shoot_left <= 0.0,
"THE SYMPTOM: the 0.5s aim bar saturated {shoot_left:.2}s ago and has nowhere to go — \
this is 'it just stays full'"
);
}
/// **The mechanism, proven.** The same session, plus the one event benilla never sends: the bar
/// immediately behaves. This is what pins the diagnosis on the missing ammo lock rather than on
/// the addon, on `SetWidth`, or on the standing-still check.
#[test]
fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
s.tick(0.1);
// The arrow leaves the quiver. This is the event the real client fires and we do not.
s.fire_event("ITEM_LOCK_CHANGED", Vec::new());
s.tick(0.1);
let (_, reloading, reload_left, _) = shot_state(&mut s);
assert!(
reloading,
"one ITEM_LOCK_CHANGED is the whole difference between a dead bar and a live one"
);
// reloadTime = UnitRangedDamage speed (2.8) - the addon's 0.5s aiming constant, less the tick.
assert!(
(2.0..=2.3).contains(&reload_left),
"the reload should be draining from ~2.3s, got {reload_left:.2}"
);
}
// ───────────────────────────────────────────────────────────────────────────────────────────────
// The Aspect Tracker — what it actually draws, and the one aspect it deliberately does not.
// ───────────────────────────────────────────────────────────────────────────────────────────────
/// Seat one active player buff by name and announce it the way the app's aura feed does.
fn seat_a_buff(s: &mut UiScript, spell_id: u32, name: &str, icon: &str) {
use benilla_ui::script::AuraState;
s.set_auras(
"player",
Some(vec![AuraState {
spell_id,
name: Some(name.into()),
icon: Some(icon.into()),
count: 0,
debuff_type: None,
duration: 0.0,
expiration_time: 0.0,
helpful: true,
cancelable: true,
until_cancelled: true,
channeled: false,
}]),
);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
}
/// Is any quad drawing this art, visibly?
///
/// **The buff bar is hidden first, and that is load-bearing.** An active aura paints its own icon
/// through `BuffButton*`, so while Aspect of the Hawk is up TWO quads carry
/// `Spell_Nature_RavenForm` — the tracker's, and the player's buff bar. An earlier cut of this
/// helper counted both and reported the tracker as drawing when it had correctly hidden itself.
fn draws(s: &mut UiScript, leaf: &str) -> bool {
use benilla_ui::script::QuadContent;
s.eval::<()>("if BuffFrame then BuffFrame:Hide() end")
.unwrap();
for i in 1..=24 {
let _ = s.eval::<()>(&format!("if BuffButton{i} then BuffButton{i}:Hide() end"));
}
s.tick(0.1);
s.resolve();
s.extract().iter().any(|q| {
q.alpha > 0.0
&& q.rect.is_some()
&& matches!(&q.content,
QuadContent::Texture { path: Some(p), .. }
if p.to_ascii_lowercase().ends_with(&leaf.to_ascii_lowercase()))
})
}
/// **The Aspect Tracker draws.** Cheetah up ⇒ the Cheetah icon is on screen.
///
/// This is the module end to end on our stack: the scanning tooltip
/// (`CreateFrame("GameTooltip", …, "GameTooltipTemplate")` → `SetPlayerBuff` → the *named*
/// `…TextLeft1` font string → a string compare against the localized spell name), then
/// `Texture:SetTexture` and the backdrop frame it lives in. Every one of those is a real
/// dependency of ours, and a break in any of them shows up here as a missing quad.
#[test]
fn aspect_tracker_draws_the_icon_for_an_active_aspect() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
seat_a_buff(
&mut s,
5118,
"Aspect of the Cheetah",
"Interface\\Icons\\Ability_Mount_JungleTiger",
);
assert!(
draws(&mut s, "Ability_Mount_JungleTiger"),
"the aspect tracker drew nothing for an active Aspect of the Cheetah — \
errors: {:#?}",
s.errors()
);
}
/// **The Hawk arm is gated on the frame LOCK, and getting that wrong looks exactly like a bug.**
///
/// `chooseIconTexture` tests seven aspects by name (Beast, Cheetah, Fox, Monkey, Viper, Wild,
/// Wolf) and then handles Hawk differently:
///
/// ```lua
/// elseif Api.Spell.PredSpellLearned(Hawk) and not Api.Aura.PredBuffActive(Hawk)
/// or not Quiver_Store.IsLockedFrames
/// ```
///
/// `and` binds tighter than `or`, so this is `(learned and NOT active) or (UNLOCKED)`. Two
/// clauses, and the second is the one that catches you out:
///
/// - the Hawk icon is a **missing-aspect reminder**, not a status light — with Hawk up it is
/// suppressed by the first clause;
/// - but **unlocked frames force it on regardless**, so you can see the frame you are dragging.
///
/// A fresh profile starts UNLOCKED, so the honest default is *the reminder shows even with Hawk
/// up*. This test asserts both halves, because an earlier cut of it asserted only "blank while
/// Hawk is up" and failed — the addon was right and the test was wrong.
#[test]
fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
seat_a_buff(
&mut s,
13165,
"Aspect of the Hawk",
"Interface\\Icons\\Spell_Nature_RavenForm",
);
// Unlocked (the fresh-profile default): the reminder shows even though Hawk IS up.
s.eval::<()>("Quiver_Store.IsLockedFrames = false").unwrap();
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
draws(&mut s, "Spell_Nature_RavenForm"),
"unlocked frames force the icon on so it can be dragged — errors: {:#?}",
s.errors()
);
// Locked: the first clause governs, and Hawk being up suppresses its own reminder.
s.eval::<()>("Quiver_Store.IsLockedFrames = true").unwrap();
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
!draws(&mut s, "Spell_Nature_RavenForm"),
"locked + Hawk up must be blank: the reminder has nothing to remind you of"
);
}
/// **B267's second half, end to end through the real feed.** Not a hand-fired event this time:
/// a container snapshot whose arrow stack ticks down by one, pushed through
/// [`crate::ui_items::feed::apply_container_source`] exactly as a server object update does.
///
/// This is the test that would have caught the bug in the first place. The A/B above proves the
/// addon reacts to `ITEM_LOCK_CHANGED`; this proves **we actually send one when an arrow is
/// spent**, which is the half that was missing (decision 1509).
#[test]
fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
use crate::ui_items::feed::{apply_container_source, FeedMemory};
use benilla_ui::script::{ContainerSlot, ContainerState};
use std::collections::HashMap;
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
let quiver = |count: u32| {
Some(HashMap::from([(
0i64,
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: HashMap::from([(
1u32,
ContainerSlot {
item_id: 2512, // Rough Arrow
count,
..Default::default()
},
)]),
},
)]))
};
let mut memory = FeedMemory::default();
apply_container_source(&mut s, &mut memory, quiver(200), Vec::new());
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
s.tick(0.1);
let (_, reloading_before, _, _) = shot_state(&mut s);
assert!(!reloading_before, "no shot has landed yet");
// The server tells us the stack is one lighter. That is a fired shot, and the only way the
// addon can ever know it.
apply_container_source(&mut s, &mut memory, quiver(199), Vec::new());
s.tick(0.1);
let (_, reloading, reload_left, _) = shot_state(&mut s);
assert!(
reloading,
"spending an arrow must start the reload drain — errors: {:#?}",
s.errors()
);
assert!(
(2.0..=2.3).contains(&reload_left),
"draining from ~2.3s (2.8s bow - 0.5s aim), got {reload_left:.2}"
);
}
@@ -266,3 +266,81 @@ fn the_reported_html_page_draws_as_blocks_not_as_its_own_markup() {
"a block was truncated — the page is height-pinned again: {drawn:?}"
);
}
/// **B288, closed at the reported symptom** (CarlG, decision 1507): the Verdant Note open from
/// the bag, then a quest giver's gossip — both frames drew at the same TOPLEFT 0,-104 anchor,
/// page text and greeting interleaved. The cause was the reader's missing `UIPanelWindows` row:
/// registered (the ref's own `{ area = "left", pushable = 0 }`, UIParent.lua l.20), the two are
/// left-slot rivals and showing one replaces the other — in BOTH orders, each displaced window's
/// OnHide ending its own session exactly as the merchant/gossip pair already does.
#[test]
fn a_quest_givers_gossip_displaces_the_open_reader() {
use benilla_ui::script::GossipMenu;
let mut s = UiScript::new().unwrap();
load_ui(&s);
// The other rival, loaded exactly as the app's manifest does (after the reader's prefix).
{
let dir = std::path::Path::new(UI_DIR);
let text = std::fs::read_to_string(dir.join("GossipFrame.xml")).unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"GossipFrame.xml: {:?}",
report.errors
);
}
// The note is open and holding the left slot.
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == 'ItemTextFrame'")
.unwrap(),
"the registered reader seats at the left slot, not a bare Show"
);
let _ = s.take_item_text_close();
// The quest giver's gossip opens: one window, not two — the reported stack cannot form.
s.set_gossip(Some(GossipMenu {
greeting: "What can I do for you?".into(),
quests: Vec::new(),
options: Vec::new(),
}));
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.take_errors().is_empty());
assert!(
!s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap(),
"gossip replaced the note — the B288 stack (both drawn at 0,-104) cannot form"
);
assert!(s
.eval::<bool>("return GossipFrame:IsShown() and GetLeftFrame():GetName() == 'GossipFrame'")
.unwrap());
assert!(
s.take_item_text_close(),
"the displaced reader's OnHide ended the read session (CloseItemText)"
);
// The app's answer to that close must not disturb the gossip that displaced it.
s.set_item_text(None);
s.fire_event("ITEM_TEXT_CLOSED", vec![]);
assert!(s.eval::<bool>("return GossipFrame:IsShown()").unwrap());
// The reverse order: reading the note over an open gossip ends the gossip session.
let _ = s.take_gossip_close();
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.take_errors().is_empty());
assert!(
s.eval::<bool>("return ItemTextFrame:IsShown() and not GossipFrame:IsShown()")
.unwrap(),
"the reader replaces gossip the same way"
);
assert!(
s.take_gossip_close(),
"the displaced gossip's OnHide ended the gossip session (CloseGossip)"
);
}
+14 -9
View File
@@ -15,8 +15,11 @@
//! wire `GOSSIP_ICON` byte. The one addition is `IsGossipOptionCoded(i)` — a benilla-local
//! 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` while its ask-once
//! query is still in flight.
//! `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
//! [`GossipMenu::greeting`] is a plain `String`.
use mlua::{Lua, MultiValue, Value};
@@ -47,12 +50,13 @@ pub struct GossipQuestRow {
pub active: bool,
}
/// One open gossip menu: the greeting body (once its query lands), the quest rows, and the option
/// rows. Pushed whole by the app; `None` means no menu is open.
/// One open gossip menu: the greeting body, the quest rows, and the option rows. Pushed whole by
/// the app; `None` means no menu is open.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GossipMenu {
/// The NPC greeting (`SMSG_NPC_TEXT_UPDATE`); `None` while the ask-once query is in flight.
pub greeting: Option<String>,
/// The NPC greeting, always resolved: a menu is only pushed once its `SMSG_NPC_TEXT_UPDATE`
/// answered — an open gossip frame with a blank page is not a reachable state (module doc).
pub greeting: String,
/// Quest rows the NPC offers/has active, riding the same packet (decision 0088).
pub quests: Vec<GossipQuestRow>,
pub options: Vec<GossipOptionView>,
@@ -86,13 +90,14 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// → the greeting body, or nil while its query is in flight / there is no menu.
// → the greeting body, or nil while there is no menu (an open menu always has one — the
// app-side hold, module doc).
g.set(
"GetGossipText",
lua.create_function(|lua, ()| {
let text = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.gossip.as_ref().and_then(|m| m.greeting.clone())
model.gossip.as_ref().map(|m| m.greeting.clone())
};
match text {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
@@ -212,7 +217,7 @@ mod tests {
fn menu() -> GossipMenu {
GossipMenu {
greeting: Some("Greetings, traveler.".into()),
greeting: "Greetings, traveler.".into(),
quests: Vec::new(),
options: vec![
GossipOptionView {
+2
View File
@@ -124,6 +124,7 @@ mod tooltip;
mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -534,6 +535,7 @@ impl UiScript {
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
ui_errors::install(&lua)?;
pointer::install(&lua)?;
action::install(&lua)?;
action_bar_toggles::install(&lua)?;
+35
View File
@@ -0,0 +1,35 @@
//! **The Lua-callable half of the client-local error-display family** — FrameXML-facing globals
//! whose whole body, in the reference binary, is "push one catalog id at
//! `CGGameUI::DisplayError`'s dispatcher". The engine-side half (a Rust system raising a key
//! directly, e.g. the passive-on-bar refusal) pushes onto the same [`Model::ui_errors`] queue;
//! the app drains it, resolves each key against the VM's own GlobalStrings and fires
//! `UI_ERROR_MESSAGE` (`ui_action/feed.rs`) — which is byte-for-byte the reference route: catalog
//! row → GlobalStrings text → FrameScript event `0xe0`, rendered by UIErrorsFrame.
//!
//! First tenant: `NotWhileDeadError` (decision 1507's open item, closed by the wow-re §5
//! cross-check of 2026-08-21, recorded in wow-re `system/ui`): registration pair `0x83e398`,
//! C body `0x48d340` = `push 0x7e; call 0x496720; xor eax,eax; ret` — no argument read, no
//! dead-check of its own, 0 return values, and its catalog row (`0xb4be70`, key
//! `ERR_PLAYER_DEAD`) names sound `"NONE"`, so the toast is silent. FrameXML decides *when* to
//! call it (ShowUIPanel's `whileDead` refusal, UIParent.lua l.663-666; ContainerFrame.lua l.147
//! and l.190); the binding only displays.
use mlua::Lua;
use super::model::Model;
/// The GlobalStrings key of catalog id `0x7e` — the row `NotWhileDeadError`'s pushed id resolves
/// to ("You can't do that when you're dead.").
const NOT_WHILE_DEAD_KEY: &str = "ERR_PLAYER_DEAD";
/// Register the error-display globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"NotWhileDeadError",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_errors.push(NOT_WHILE_DEAD_KEY);
Ok(())
})?,
)
}