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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -698,6 +698,9 @@ mod perf_bar_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fade_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod merchant_tests;
|
||||
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user