diff --git a/crates/benilla-app/assets/ui/PetStable.xml b/crates/benilla-app/assets/ui/PetStable.xml
deleted file mode 100644
index 6c492640..00000000
--- a/crates/benilla-app/assets/ui/PetStable.xml
+++ /dev/null
@@ -1,715 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- this:RegisterForDrag("LeftButton")
-
- -- xml l.31-38. Guarded on `this.tooltip` because Update has not necessarily run
- -- yet: a hover before the first repaint has nothing to say and says nothing.
- if this.tooltip then
- GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
- GameTooltip:SetText(this.tooltip)
- GameTooltip:AddLine(this.tooltipSubtext, "", 1.0, 1.0, 1.0)
- GameTooltip:Show()
- end
-
- GameTooltip:Hide()
-
- if ClickStablePet(this:GetID()) then
- PetStable_Update()
- end
-
- PickupStablePet(this:GetID())
-
- if ClickStablePet(this:GetID()) then
- PetStable_Update()
- end
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
- GameTooltip:SetText(this.tooltip)
-
- GameTooltip:Hide()
-
-
-
-
-
- Model_OnLoad()
- Model_OnUpdate(arg1, this)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SmallMoneyFrame_OnLoad() MoneyFrame_SetType("STATIC")
-
-
-
-
-
-
-
- PetStable_OnLoad()
- PlaySound("igCharacterInfoOpen")
- ClosePetStables() PlaySound("igCharacterInfoClose")
- PetStable_OnEvent()
-
-
-
diff --git a/crates/benilla-app/assets/ui/QuestLogFrame.xml b/crates/benilla-app/assets/ui/QuestLogFrame.xml
index 1c44086e..6e6aaa93 100644
--- a/crates/benilla-app/assets/ui/QuestLogFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestLogFrame.xml
@@ -1071,6 +1071,44 @@
-- `doNotScroll` (pin §5's asymmetry, threaded through to `BenillaQuestLogDetail_Update`):
-- absent/falsy for a manual call (a row click, the window's own OnShow); `true` from the
-- QUEST_LOG_UPDATE event handler below.
+ -- ── The reference's own two names for what this window already does ─────────────────
+ --
+ -- `QuestTimerFrame.xml` is the reference's own file, on the chain since 1751, and its
+ -- `QuestTimerButton_OnClick` (QuestTimerFrame.lua:41-45) does exactly three things:
+ -- ShowUIPanel, `QuestLog_SetSelection(...)`, `QuestLog_Update()`. Neither of the last two
+ -- existed anywhere in this tree, so clicking a quest timer raised. Defining them here is
+ -- not a stub of an engine verb (1203) — both are FrameXML functions, and this file is our
+ -- stand-in for `QuestLogFrame.lua`, which is where the reference defines them. Every other
+ -- function in this window has the same relationship to its stock twin; these two were
+ -- simply missed because nothing of ours called them.
+ --
+ -- `QuestLog_SetSelection`'s stock body (QuestLogFrame.lua:317-345) is mostly frame work
+ -- that only the stock window has — `QuestLogTitle`..id, `QuestLogHighlightFrame`,
+ -- `QuestLogSkillHighlight`. What survives the re-expression is its LAW: a header never
+ -- becomes the selection (it folds instead), and a real quest is selected and repainted.
+ -- That is the same fork `BenillaQuestLogTitle_OnClick` above already implements, so this
+ -- is the shared body rather than a second copy of it.
+ function QuestLog_SetSelection(index)
+ if not index or index == 0 then return end
+ local _, _, _, isHeader, isCollapsed = GetQuestLogTitle(index)
+ if isHeader then
+ if isCollapsed and type(ExpandQuestHeader) == "function" then
+ ExpandQuestHeader(index)
+ elseif type(CollapseQuestHeader) == "function" then
+ CollapseQuestHeader(index)
+ end
+ return
+ end
+ SelectQuestLogEntry(index)
+ end
+
+ -- The reference's `QuestLog_Update` (QuestLogFrame.lua:107). Ours has carried this body
+ -- under a `Benilla` prefix; the stock name is the one the chain calls, and having it here
+ -- also shrinks the delta when this window itself migrates.
+ function QuestLog_Update(doNotScroll)
+ BenillaQuestLogFrame_Update(doNotScroll)
+ end
+
function BenillaQuestLogFrame_Update(doNotScroll)
QuestLog_SetFirstValidSelection()
BenillaQuestLogList_Update()
diff --git a/crates/benilla-app/assets/ui/TaxiFrame.xml b/crates/benilla-app/assets/ui/TaxiFrame.xml
deleted file mode 100644
index afbebfe3..00000000
--- a/crates/benilla-app/assets/ui/TaxiFrame.xml
+++ /dev/null
@@ -1,557 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- TaxiFrame_OnLoad()
-
-
- TaxiFrame_OnEvent(event)
-
-
- BenillaTaxiFrame_OnShow()
-
-
- BenillaTaxiFrame_OnHide()
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index fb27419c..47b55922 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -755,11 +755,19 @@ TrainerFrame.xml
#
# Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi, and after MerchantFrame.
Interface\FrameXML\BankFrame.xml
-# The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
-# taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
-# Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
-# needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
-TaxiFrame.xml
+# ══ THE FLIGHT MAP — the reference's own, off the player's chain (1751) ══
+# The flight-master session (decision 0484 phase 2), driven by the taxi engine seam
+# (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain — unchanged; only the window
+# in front of it is the reference's now. Our 557-line transcription carried the same five stock
+# globals plus eight helpers of its own, none of which anything outside that file named.
+#
+# The one thing it needed was the `` widget type, which is registered now (1828) —
+# a `CSimpleFrame` and nothing else, so it is `FrameKind::Frame` and the route lines stay what the
+# reference makes them: plain `` children rotated by an 8-argument `SetTexCoord`.
+#
+# Pulls only `TaxiFrame.lua`, and still needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney),
+# all loaded above — so it keeps its seat in the "NPC session windows grouped" cluster.
+Interface\FrameXML\TaxiFrame.xml
# The stable-master window (decision 1676): the hunter's three-slot pet stable over the stable
# API (`benilla-ui/src/script/stable.rs`) + the app's feed/drain. Kept in the "NPC session
# windows grouped" cluster beside Trainer/Bank/Taxi. Two of its dependencies are resolved at
@@ -771,7 +779,13 @@ TaxiFrame.xml
# longer has to, and stays here only because the NPC-session cluster is where it belongs. The rest
# (ItemButtonTemplate's SetItemButtonTexture, GameTooltip, UiPanels' ShowUIPanel/HideUIPanel,
# UIParent's BuildListString) are resolved when the window repaints, so their order is free.
-PetStable.xml
+#
+# ══ THE REFERENCE'S OWN, off the player's chain (1751) ══
+# A true 1:1 swap: our transcription and the stock file declare the SAME frame names and the SAME
+# four globals (`PetStable_OnLoad`/`_OnEvent`/`_Update`/`_NoPetsAllowed`), so nothing that names
+# either moves — `ui_stable.rs`, `names.rs` and `net/apply/npc.rs` all reach it by stock names
+# already, and `portrait`'s booth keys on `PetStableModel`, which both spell the same.
+Interface\FrameXML\PetStable.xml
# The auction house (decision 1511): the three-tab doublewide window over the auction API
# (`benilla-ui/src/script/auction.rs`) + the app's feed/drain. Kept in the "NPC session
# windows grouped" cluster beside Trainer/Bank/Taxi, and loaded AFTER MoneyFrame (its eight
diff --git a/crates/benilla-app/src/debug_panel/world.rs b/crates/benilla-app/src/debug_panel/world.rs
index 4f71380f..3d86425d 100644
--- a/crates/benilla-app/src/debug_panel/world.rs
+++ b/crates/benilla-app/src/debug_panel/world.rs
@@ -42,6 +42,7 @@ pub(super) struct WorldReadout<'w, 's> {
room: Res<'w, benilla_world::wmo_portal::CameraInteriorClaim>,
windows: Res<'w, benilla_world::wmo_portal::ExteriorWindows>,
skybox: Res<'w, benilla_world::skybox::CameraSkybox>,
+ skybox_weight: Res<'w, benilla_world::skybox::SkyboxWeight>,
streamer: Res<'w, benilla_world::terrain_stream::TerrainStreamer>,
self_guid: Res<'w, crate::net::SelfGuid>,
names: ResMut<'w, crate::names::NameCache>,
@@ -100,11 +101,17 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
// The WMO skybox engagement, right beside the interior claim it derives from: both come off
// the camera's down-ray seed, so when the backdrop flips between the building's painted sky
// and the Light.dbc gradient, this is the line that says which — and whether the claim moved
- // under it. From the chair the two are only distinguishable by colour.
+ // under it. From the chair the two are only distinguishable by colour. The weight is the
+ // 4-second crossfade (== the interior-fog blend, one number): `w 0.00` beside a name means
+ // the flood published a skybox the crossfade hasn't engaged — faithful standing outside
+ // the gate, not a resolve bug.
match world.skybox.0.as_deref() {
Some(path) => {
let leaf = path.rsplit('\\').next().unwrap_or(path);
- line.push_str(&format!(" · skybox {leaf}"));
+ line.push_str(&format!(
+ " · skybox {leaf} w {:.2}",
+ world.skybox_weight.0
+ ));
}
None => line.push_str(" · sky gradient"),
}
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 242f12a8..3c434407 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -1860,8 +1860,12 @@ pub(super) fn apply_net_updates(
new_count,
..
} => npc::vendor_buy_result(vendor, slot, new_count, &mut merchant),
- SessionEvent::VendorBuyFailed { reason, .. } => {
- npc::vendor_buy_failed(reason, &mut ui_actions.3)
+ SessionEvent::VendorBuyFailed {
+ vendor,
+ item_entry,
+ reason,
+ } => {
+ npc::vendor_buy_failed(vendor, item_entry, reason, &mut merchant, &mut ui_actions.3)
}
SessionEvent::VendorSellFailed { reason, .. } => {
npc::vendor_sell_failed(reason, &mut ui_actions.3)
diff --git a/crates/benilla-app/src/net/apply/npc.rs b/crates/benilla-app/src/net/apply/npc.rs
index 4c14fee3..608650fe 100644
--- a/crates/benilla-app/src/net/apply/npc.rs
+++ b/crates/benilla-app/src/net/apply/npc.rs
@@ -306,9 +306,33 @@ pub(super) fn vendor_buy_result(
}
}
-/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line.
-pub(super) fn vendor_buy_failed(reason: u8, errors: &mut MerchantErrors) {
- debug!("net: buy failed (reason {reason})");
+/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line, and for the
+/// out-of-stock code the refusing row's own count.
+///
+/// **`ITEM_ALREADY_SOLD` zeroes the row** (`0x5dcda7`..`0x5dcdd6`, decision 1821): the reference
+/// walks its 128-row vendor cache, writes 0 into the matching row's count word and repaints —
+/// gated, as every other stale-answer path here is, on the packet naming the vendor still open.
+///
+/// **NAMED DIVERGENCE in the key.** The reference matches the row's `+0x00`, the vendor *slot* —
+/// the same word `SMSG_BUY_ITEM` keys its stock update by. vmangos puts the item *entry* in that
+/// field (`Player::SendBuyError`, `Player.cpp:11637`: `packet->itemEntry = item`), so matching by
+/// slot would find nothing on the server we actually talk to; matching by entry is the same row.
+/// vmangos raises this code from exactly the condition the zeroing models —
+/// `GetVendorItemCurrentCount(crItem) < totalCount` under `crItem->maxcount != 0`
+/// (`Player.cpp:18508`).
+pub(super) fn vendor_buy_failed(
+ vendor: u64,
+ item_entry: u32,
+ reason: u8,
+ merchant: &mut MerchantOpen,
+ errors: &mut MerchantErrors,
+) {
+ debug!("net: buy failed (entry {item_entry}, reason {reason})");
+ if reason == benilla_protocol::messages::buy_result::ITEM_ALREADY_SOLD
+ && merchant.vendor == Some(vendor)
+ {
+ merchant.sold_out(item_entry);
+ }
errors.0.push(MerchantRefusal::Buy(reason));
}
diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs
index 1674bd14..e85fc0c9 100644
--- a/crates/benilla-app/src/ui_action/errors.rs
+++ b/crates/benilla-app/src/ui_action/errors.rs
@@ -205,6 +205,17 @@ impl Shown {
}
}
+/// Resolve a message key against the VM's own `GlobalStrings.lua` into a [`Shown`] — or `None`
+/// when the key has no string there, which is the reference's own data-suppression face
+/// (`0x4967bd`/`0x4967c5`) and the reason every raise site carries a *key* rather than text.
+///
+/// Lives here rather than in each window because every keyed raise site needs exactly this and
+/// four of them had grown their own copy (decision 1821).
+pub(crate) fn keyed_line(script: &UiScript, key: &'static str) -> Option {
+ let text = script.lua().globals().get::(key).ok()?;
+ (!text.is_empty()).then(|| Shown::keyed(key, text))
+}
+
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and
/// queue the sound its catalog row names.
///
diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs
index bff97678..65a291f3 100644
--- a/crates/benilla-app/src/ui_action/mod.rs
+++ b/crates/benilla-app/src/ui_action/mod.rs
@@ -66,8 +66,8 @@ pub(crate) struct CooldownEvents;
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
- attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors,
- CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys, UiErrorTexts,
+ attack_actor_refusal, keyed_line, reagent_totem_refusal, show_messages, ui_error_text,
+ CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys, UiErrorTexts,
};
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
diff --git a/crates/benilla-app/src/ui_bank.rs b/crates/benilla-app/src/ui_bank.rs
index b642d603..df6f7233 100644
--- a/crates/benilla-app/src/ui_bank.rs
+++ b/crates/benilla-app/src/ui_bank.rs
@@ -103,8 +103,13 @@ impl Plugin for UiBankPlugin {
}
/// The red error line for a `SMSG_BUY_BANK_SLOT_RESULT` code — the reference GlobalStrings'
-/// `ERR_BANKSLOT_*` texts verbatim (decision 0604: the codes map 1:1 onto them). `OK` never
-/// prints (vmangos doesn't send it; tolerated silently if a server ever does).
+/// `ERR_BANKSLOT_*` texts verbatim (decision 0604: the codes map 1:1 onto them).
+///
+/// **The three ids are the reference's own table, and `None` is the reference's own silence**
+/// (decision 1821): its handler at `0x5e3f8d` reads the `u32`, refuses `>= 3` outright
+/// (`0x5e3f9c cmp eax,0x3; jae`) and otherwise indexes a 3-entry table at `0x80af14` holding
+/// exactly `{0x100, 0x101, 0x102}`. So `OK` (3) is silent by the same bound as an unknown code —
+/// there is no per-code fallback line to print, and printing one would be our invention.
fn bank_slot_error_key(result: u32) -> Option<&'static str> {
Some(match result {
bank_slot_result::FAILED_TOO_MANY => "ERR_BANKSLOT_FAILED_TOO_MANY", // 0x100
@@ -139,22 +144,12 @@ fn feed_bank(
let last = last.get(&script);
let last_banker = last_banker.get(&script);
// Purchase refusals go to the surface — and the voice — their message record names: the
- // insufficient-funds row carries error-speech line `0x16` (decision 1815). A code with no key
- // keeps its "(n)" line and stays silent, because there is no record behind it to sound.
+ // insufficient-funds row carries error-speech line `0x16` (decision 1815). A code outside the
+ // reference's three is silent, exactly as its `jae` makes it ([`bank_slot_error_key`]).
let lines: Vec<_> = errors
.0
.drain(..)
- .filter_map(|result| match bank_slot_error_key(result) {
- Some(key) => {
- let text = script.lua().globals().get::(key).ok()?;
- (!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
- }
- None if result == bank_slot_result::OK => None,
- None => Some(crate::ui_action::Shown::unkeyed(
- benilla_ui::messages::MsgKind::Error,
- format!("Bank slot purchase failed ({result})."),
- )),
- })
+ .filter_map(|result| crate::ui_action::keyed_line(&script, bank_slot_error_key(result)?))
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_bank", lines);
let store = self_q.iter().next();
@@ -220,7 +215,9 @@ mod tests {
use super::*;
/// The refusal-code → red-line map: the three vmangos failure codes print the reference
- /// GlobalStrings verbatim, OK stays silent, an unknown code prints its number.
+ /// GlobalStrings verbatim, and everything else — `OK` and any unknown code alike — is silent,
+ /// which is the reference's own `cmp eax,0x3; jae` and not a policy of ours (1821). The ids
+ /// are asserted, not just the keys: those are what `0x80af14` actually holds.
#[test]
fn bank_slot_error_keys() {
assert_eq!(
@@ -236,14 +233,20 @@ mod tests {
Some("ERR_BANKSLOT_NOTBANKER")
);
assert_eq!(bank_slot_error_key(bank_slot_result::OK), None);
- assert_eq!(bank_slot_error_key(99), None, "the feed's own \"(n)\" arm");
- // Every key is a catalog row, and the insufficient-funds one is the spoken one (1815).
- for key in [
- "ERR_BANKSLOT_FAILED_TOO_MANY",
- "ERR_BANKSLOT_INSUFFICIENT_FUNDS",
- "ERR_BANKSLOT_NOTBANKER",
+ assert_eq!(
+ bank_slot_error_key(99),
+ None,
+ "past the reference's own bound"
+ );
+ // Every key is a catalog row, at the id `0x80af14` holds for its code; and the
+ // insufficient-funds one is the spoken one (1815).
+ for (key, id) in [
+ ("ERR_BANKSLOT_FAILED_TOO_MANY", 0x100u16),
+ ("ERR_BANKSLOT_INSUFFICIENT_FUNDS", 0x101),
+ ("ERR_BANKSLOT_NOTBANKER", 0x102),
] {
let r = benilla_ui::messages::by_key(key).expect("catalog row");
+ assert_eq!(r.id, id, "{key}");
assert_eq!(r.kind, benilla_ui::messages::MsgKind::Error, "{key}");
}
assert_eq!(
diff --git a/crates/benilla-app/src/ui_logout.rs b/crates/benilla-app/src/ui_logout.rs
index 2580f11e..82e56b4e 100644
--- a/crates/benilla-app/src/ui_logout.rs
+++ b/crates/benilla-app/src/ui_logout.rs
@@ -20,32 +20,39 @@
//! name (QUIT_TIMER "%d %s until exit"), which is why the reference gives QUIT an "Exit now" button
//! and CAMP none — a force-quit needs no server round trip, a force-logout does.
//!
-//! **Pinned vs inferred.** The wire shape is verified twice over: vmangos's packet
-//! (`WorldPackets::Misc::LogoutResponse` — `u32 reason, u8 instant`) and the real client's own
-//! handler (wow-re `system/net/ledger.tsv` 0x5b4630, `Handle(0x4c) — {u32, u8}`). The 1.12
-//! FrameXML fixes the UI half exactly: the CAMP/QUIT dialogs, their 20 s timeouts, and their
+//! **All of it is now pinned** (decision 1821 — this module's three arms were the last INFERRED
+//! join left in the leaving path). The wire shape is verified twice over: vmangos's packet
+//! (`WorldPackets::Misc::LogoutResponse` — `u32 reason, u8 instant`) and the reference's own
+//! handler (`0x5b4630`: `GetInt32` then `GetUInt8`, then a virtual `[vtable+0x54](reason,
+//! instant)`, which is `[0x80a398+0x54]` = `0x5aaef0`). That override is this decision table byte
+//! for byte:
+//!
+//! - `0x5aaef6 test eax,eax / jne 0x5aaf21` → **any** non-zero reason takes one arm:
+//! `DisplayError(0x180)` = `ERR_LOGOUT_FAILED`, then clear the pending flag. There is no
+//! per-reason string — combat, falling and GM-frozen all show the one line.
+//! - `0x5aaf00 test eax,eax / jne` → `instant != 0` returns having done nothing; the completion
+//! is already on its way.
+//! - otherwise `setne cl` on `[this+0x1b1c]`, `add ecx,0x114`, `SignalEvent` → event **276
+//! `PLAYER_CAMPING`** or **277 `PLAYER_QUITING`**, and `[this+0x1b1c]` is written from the
+//! logout *request*'s own quit flag at `0x5ab053`. The reference remembers camp-vs-quit in one
+//! local byte, which is exactly what [`LogoutState::quitting`] is.
+//!
+//! The 1.12 FrameXML fixes the rest: the CAMP/QUIT dialogs, their 20 s timeouts, and their
//! `PLAYER_CAMPING` / `PLAYER_QUITING` / `LOGOUT_CANCEL` drivers (UIParent.lua l.304-315, event
-//! ids 276/277/278 in wow-re's `re/events/event-catalog.tsv`). What is **INFERRED** is the join
-//! between them — that the client fires PLAYER_CAMPING off exactly this packet's `instant == 0` —
-//! because the 0x4c handler's body isn't RE'd. It is the only wiring consistent with what the
-//! reference client observably does (no dialog when you camp in an inn, a 20 s dialog in the
-//! field, matching the server's own timer), and the falsification is cheap: a dialog that appears
-//! on an inn logout, or none in the field, means the trigger is elsewhere. The refusal string is
-//! inferred the same way (see [`ERR_LOGOUT_FAILED`]).
+//! ids 276/277/278 in wow-re's `re/events/event-catalog.tsv`).
-use benilla_ui::script::{ScriptValue, SessionRequest, UiScript};
+use benilla_ui::script::{SessionRequest, UiScript};
use bevy::prelude::*;
use crate::net::{ClientCommand, LoggedOutMessage, NetCommands, SelfGuid};
use crate::ui_script::UiInput;
-/// The reference's own refusal line (GlobalStrings.lua `ERR_LOGOUT_FAILED`), shown when the server
-/// answers a non-zero reason. **INFERRED**: which of 1.12's several logout-failure strings the real
-/// client picks per reason code isn't RE'd, and vmangos's three reasons (combat / falling / frozen)
-/// have no distinct strings in the ERR_ table — this generic one covers all three, and is the line
-/// vanilla is remembered for. `PLAYER_LOGOUT_FAILED_ERROR` ("…because you can't sit down right
-/// now") is the other candidate and would be the correction if a capture ever shows it.
-const ERR_LOGOUT_FAILED: &str = "You can't logout now.";
+/// The reference's refusal line, as a **catalog key** rather than a literal — message id `0x180`,
+/// the sole argument of the `DisplayError` at `0x5aaf26` (decision 1821). VERIFIED, superseding
+/// this module's old inference: the reference picks this one string for every non-zero reason,
+/// and `PLAYER_LOGOUT_FAILED_ERROR` — the other candidate the guess weighed — is never reached
+/// from here. The text comes from the VM's own `GlobalStrings.lua`, so a locale rides for free.
+const LOGOUT_FAILED_KEY: &str = "ERR_LOGOUT_FAILED";
/// What the wire told us to say next — drained by [`feed_logout`] into script events. A queue
/// rather than a flag: a cancel can land in the same frame as the response it cancels, and the UI
@@ -98,7 +105,9 @@ impl LogoutState {
});
}
- /// `SMSG_LOGOUT_CANCEL_ACK` — the countdown is off.
+ /// `SMSG_LOGOUT_CANCEL_ACK` — the countdown is off. Its handler is the vtable slot next door
+ /// (`0x5b4680 call [eax+0x58]` → `0x5aaf60`), and all it does is fire event 278
+ /// `LOGOUT_CANCEL` off the same pending byte the refusal arm clears (decision 1821).
pub(crate) fn apply_cancelled(&mut self) {
self.quitting = false;
self.signals.push(LogoutSignal::Cancelled);
@@ -106,7 +115,15 @@ impl LogoutState {
}
/// Fire the camp/quit events the dialogs are driven by (GameMenuFrame.xml's `BenillaLogoutDriver`).
-fn feed_logout(script: Option>, mut logout: ResMut) {
+///
+/// The refusal is shown one at a time through the message sink rather than collected, because the
+/// signals are ordered — a cancel can land in the same frame as the response it cancels, and the
+/// dialogs must see both in the order the wire delivered them.
+fn feed_logout(
+ script: Option>,
+ mut logout: ResMut,
+ mut sink: crate::ui_action::MessageSink,
+) {
let Some(mut script) = script else {
return;
};
@@ -115,10 +132,10 @@ fn feed_logout(script: Option>, mut logout: ResMut script.fire_event("PLAYER_CAMPING", Vec::new()),
LogoutSignal::Quiting => script.fire_event("PLAYER_QUITING", Vec::new()),
LogoutSignal::Cancelled => script.fire_event("LOGOUT_CANCEL", Vec::new()),
- LogoutSignal::Refused => script.fire_event(
- "UI_ERROR_MESSAGE",
- vec![ScriptValue::Str(ERR_LOGOUT_FAILED.into())],
- ),
+ LogoutSignal::Refused => {
+ let line = crate::ui_action::keyed_line(&script, LOGOUT_FAILED_KEY);
+ crate::ui_action::show_messages(&mut script, &mut sink, "ui_logout", line);
+ }
}
}
}
diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs
index f94488fe..bab36c9f 100644
--- a/crates/benilla-app/src/ui_mail/mod.rs
+++ b/crates/benilla-app/src/ui_mail/mod.rs
@@ -97,8 +97,8 @@ pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
/// button). `MAIL_SEND_SUCCESS` fires additionally when `error == OK`.
pub(crate) struct MailSendAck {
pub(crate) ok: bool,
- /// A pre-formatted red error line for a non-OK, non-equip send failure (else `None`).
- pub(crate) refusal: Option,
+ /// The message key for a non-OK, non-equip send failure (else `None`).
+ pub(crate) refusal: Option<&'static str>,
}
/// The open mailbox, filled by the net bridge ([`crate::net::apply::mail`]) and read by
@@ -124,9 +124,10 @@ pub(crate) struct MailOpen {
last_list_query: Option,
/// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]).
pub(crate) send_acks: Vec,
- /// Refusals (a take/return/delete failure) the feed shows, resolved at the feed because a
- /// keyed one reads its text out of the VM ([`MailRefusal`]).
- pub(crate) errors: Vec,
+ /// Refusals (a take/return/delete failure) the feed shows, as message keys — resolved at the
+ /// feed, which is where the VM's `GlobalStrings` can be read
+ /// ([`crate::ui_action::keyed_line`]).
+ pub(crate) errors: Vec<&'static str>,
}
impl MailOpen {
@@ -239,56 +240,36 @@ fn send_query_next_mail_time_on_enter(
}
}
-/// One queued mail refusal, on its way to the red line.
+/// The message a `SMSG_SEND_MAIL_RESULT` code shows — **always** a catalog key, which is the whole
+/// correction decision 1821 brought here (superseding 0544's hand-written pair).
///
-/// Two arms because 1.12's own vocabulary has two: most `MailResponseResult` codes name a
-/// **message-catalog key**, whose row decides the text (from the player's own `GlobalStrings`),
-/// the surface, and — for `ERR_NOT_ENOUGH_MONEY`, line `0x28` — the *voice* (decision 1815). Two
-/// codes have no 1.12 string at all, and those keep the best-effort English literal they have
-/// always had: no row, so no sound, which is the honest shape rather than a borrowed one.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum MailRefusal {
- Key(&'static str),
- Text(String),
-}
-
-/// The refusal a `SMSG_SEND_MAIL_RESULT` error shows (vmangos `MailResponseResult`); keys
-/// cross-checked against the reference `GlobalStrings.lua` — every one of them resolves there to
-/// exactly the string this used to inline (decision 0544, now read at runtime instead).
-pub(crate) fn mail_refusal(error: u32) -> MailRefusal {
+/// The reference's handler `0x4ad050` indexes `[0x4ad1a0 + error]` into a nine-entry jump table at
+/// `0x4ad17c`, every arm a `push ; call CGGameUI::DisplayError`. This module used to claim
+/// *"No 1.12 GlobalString for either"* about codes 5 and 14 and inline near-miss English for them;
+/// both are real rows, and their real strings differ from what we were showing. And the arm this
+/// module used to spell `"Mail failed (n)."` is the reference's own `ja 0x4ad147` — every code
+/// from 6 to 13 and everything past 15 lands on `ERR_MAIL_DATABASE_ERROR` with them.
+///
+/// `OK` and `EQUIP_ERROR` never reach here: the first is not a refusal (it has
+/// [`MAIL_SENT_KEY`] instead) and the second resolves through the equip-error table
+/// (`0x4ad12e` → `0x622630`), which is [`crate::ui_items`]' job.
+pub(crate) fn mail_refusal(error: u32) -> &'static str {
match error {
- mail_error::CANNOT_SEND_TO_SELF => MailRefusal::Key("ERR_MAIL_TO_SELF"), // 0x164
- mail_error::NOT_ENOUGH_MONEY => MailRefusal::Key("ERR_NOT_ENOUGH_MONEY"), // 0x25
- mail_error::RECIPIENT_NOT_FOUND => MailRefusal::Key("ERR_MAIL_TARGET_NOT_FOUND"), // 0x165
- mail_error::INTERNAL_ERROR => MailRefusal::Key("ERR_MAIL_DATABASE_ERROR"), // 0x166
- mail_error::TOO_MANY_ATTACHMENTS => MailRefusal::Key("ERR_MAIL_REACHED_CAP"), // 0x1c4
- // No 1.12 GlobalString for either — flagged literals, as before.
- mail_error::NOT_YOUR_TEAM => {
- MailRefusal::Text("That player is not part of your alliance.".into())
- }
- mail_error::TRIAL_ACCOUNT => {
- MailRefusal::Text("Trial accounts cannot perform that action.".into())
- }
- other => MailRefusal::Text(format!("Mail failed ({other}).")),
+ mail_error::CANNOT_SEND_TO_SELF => "ERR_MAIL_TO_SELF", // 0x164
+ mail_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
+ mail_error::RECIPIENT_NOT_FOUND => "ERR_MAIL_TARGET_NOT_FOUND", // 0x165
+ mail_error::NOT_YOUR_TEAM => "ERR_PLAYER_WRONG_FACTION", // 0xff
+ mail_error::TRIAL_ACCOUNT => "ERR_RESTRICTED_ACCOUNT", // 0x1be
+ mail_error::TOO_MANY_ATTACHMENTS => "ERR_MAIL_REACHED_CAP", // 0x1c4
+ // INTERNAL_ERROR (6) and every other code, by the same `0x4ad147`.
+ _ => "ERR_MAIL_DATABASE_ERROR", // 0x166
}
}
-impl MailRefusal {
- /// Resolve to a displayable line, reading a keyed one out of the VM's own `GlobalStrings`.
- /// `None` for a key whose string is absent or empty — the reference's data-suppression face.
- fn shown(self, get: &dyn Fn(&str) -> Option) -> Option {
- match self {
- Self::Key(key) => {
- let text = get(key)?;
- (!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
- }
- Self::Text(text) => Some(crate::ui_action::Shown::unkeyed(
- benilla_ui::messages::MsgKind::Error,
- text,
- )),
- }
- }
-}
+/// The line a **successful** `SEND` shows: `ERR_MAIL_SENT` — id `0x167`, and a `kind 1` row, so it
+/// is the yellow info line and not the red one. `0x4ad0b1` reaches it before the form reset
+/// (`0x4acdc0`), gated on the action being `SEND`; a successful take/return/delete says nothing.
+pub(crate) const MAIL_SENT_KEY: &str = "ERR_MAIL_SENT";
mod invoice;
@@ -532,21 +513,25 @@ fn feed_mail(
// Take/return/delete failures go to the surface — and the voice — their message record names.
let refusals: Vec<_> = std::mem::take(&mut mail.errors)
.into_iter()
- .filter_map(|r| r.shown(&|key| script.lua().globals().get::(key).ok()))
+ .filter_map(|key| crate::ui_action::keyed_line(&script, key))
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", refusals);
// SEND acks: MAIL_FAILED on EVERY send result (unblocks the button — wow-re §5), plus
- // MAIL_SEND_SUCCESS on OK (resets the form) or the red error line on a failure.
+ // MAIL_SEND_SUCCESS on OK (resets the form) or the refusal's own line on a failure. The
+ // success also SAYS so — `ERR_MAIL_SENT`, the yellow info line `0x4ad0b1` shows before it
+ // resets the form (decision 1821).
for ack in std::mem::take(&mut mail.send_acks) {
script.fire_event("MAIL_FAILED", vec![]);
+ let key = if ack.ok {
+ Some(MAIL_SENT_KEY)
+ } else {
+ ack.refusal
+ };
+ let line = key.and_then(|k| crate::ui_action::keyed_line(&script, k));
+ crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", line);
if ack.ok {
script.fire_event("MAIL_SEND_SUCCESS", vec![]);
script.clear_send_mail_item();
- } else if let Some(line) = ack
- .refusal
- .and_then(|r| r.shown(&|key| script.lua().globals().get::(key).ok()))
- {
- crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", [line]);
}
}
@@ -772,6 +757,43 @@ mod tests {
use super::*;
use benilla_protocol::messages::MailAttachment;
+ /// **The mail-result table, welded to the message ids it was read from** (decision 1821) —
+ /// the reference's jump table at `0x4ad17c`, reached through the byte index at `0x4ad1a0`.
+ /// The id, not the key, is what was disassembled, so the id is what this asserts.
+ #[test]
+ fn the_mail_result_table_is_the_references_own() {
+ let id = |key: &str| {
+ benilla_ui::messages::by_key(key)
+ .unwrap_or_else(|| panic!("{key} is not a catalog row"))
+ .id
+ };
+ for (code, want) in [
+ (mail_error::CANNOT_SEND_TO_SELF, 0x164u16),
+ (mail_error::NOT_ENOUGH_MONEY, 0x25),
+ (mail_error::RECIPIENT_NOT_FOUND, 0x165),
+ (mail_error::NOT_YOUR_TEAM, 0xff),
+ (mail_error::INTERNAL_ERROR, 0x166),
+ (mail_error::TRIAL_ACCOUNT, 0x1be),
+ (mail_error::TOO_MANY_ATTACHMENTS, 0x1c4),
+ ] {
+ assert_eq!(id(mail_refusal(code)), want, "mail error {code}");
+ }
+ // `ja 0x4ad147` and the index table's `08` run: 7..13 and everything past 15 share the
+ // database-error line, and there is no "(n)" arm for them to take.
+ for code in [7, 8, 13, 16, 99, u32::MAX] {
+ assert_eq!(id(mail_refusal(code)), 0x166, "mail error {code}");
+ }
+ // A success SAYS so — and on the YELLOW line, which is the half a red-only path would
+ // have got wrong.
+ assert_eq!(id(MAIL_SENT_KEY), 0x167);
+ assert_eq!(
+ benilla_ui::messages::by_key(MAIL_SENT_KEY)
+ .expect("row")
+ .kind,
+ benilla_ui::messages::MsgKind::Info
+ );
+ }
+
/// The no-subject macro context these row tests run under: none of them exercises a `$` token,
/// and a subject-less context leaves plain text untouched (the expander only rewrites at a `$`).
/// The macro path's own behaviour is covered in [`crate::npc_text`]'s tests.
diff --git a/crates/benilla-app/src/ui_merchant.rs b/crates/benilla-app/src/ui_merchant.rs
index 0c6f58ac..abdd1b53 100644
--- a/crates/benilla-app/src/ui_merchant.rs
+++ b/crates/benilla-app/src/ui_merchant.rs
@@ -92,6 +92,17 @@ impl MerchantOpen {
}
}
+ /// Mark every row of an item **sold out** — the `SMSG_BUY_FAILED` `ITEM_ALREADY_SOLD` arm
+ /// (`0x5dcdbf mov DWORD PTR [esi],0x0`), so a limited-stock row that just refused the click
+ /// stops showing the count that tempted it. Unconditional, as the reference's write is: only a
+ /// limited row can raise this refusal, and the reference does not break on the first match
+ /// either (`0x5dcdcd` walks all 128 cache rows).
+ pub(crate) fn sold_out(&mut self, entry: u32) {
+ for it in self.items.iter_mut().filter(|it| it.entry == entry) {
+ it.current_count = 0;
+ }
+ }
+
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
pub(crate) fn clear(&mut self) {
self.vendor = None;
@@ -143,36 +154,53 @@ impl Plugin for UiMerchantPlugin {
}
}
-/// The client's message string for a `BuyResult` refusal (`SMSG_BUY_FAILED`); values from
-/// [`buy_result`] (vmangos `ItemDefines.h`). Unknown reasons print their code.
-fn buy_error_text(reason: u8) -> String {
- match reason {
- buy_result::CANT_FIND_ITEM => "That item does not exist.".into(),
- buy_result::ITEM_ALREADY_SOLD => "That item has already been sold.".into(),
- buy_result::NOT_ENOUGH_MONEY => "You don't have enough money.".into(),
- buy_result::SELLER_DONT_LIKE_YOU => "The vendor doesn't like you.".into(),
- buy_result::DISTANCE_TOO_FAR => "You are too far away.".into(),
- buy_result::ITEM_SOLD_OUT => "The vendor is sold out of that item.".into(),
- buy_result::CANT_CARRY_MORE => "You can't carry any more of those items.".into(),
- buy_result::RANK_REQUIRE => "You don't have the required rank for that item.".into(),
- buy_result::REPUTATION_REQUIRE => {
- "You don't have the required reputation for that item.".into()
- }
- other => format!("Purchase failed ({other})."),
- }
+/// The one `BuyResult` code the reference deliberately says **nothing** for. It has no vmangos
+/// name because vmangos never sends it: `0x5dcde7`'s jump table sends code 6 to the handler's own
+/// `ret`, past the `DisplayError` its neighbours land on.
+const BUY_SILENT: u8 = 6;
+
+/// The GlobalStrings key a `BuyResult` refusal (`SMSG_BUY_FAILED`) shows — the reference's switch
+/// at `0x5dcdd8`, each arm a `CGGameUI::DisplayError(msgId)` call, read off the binary for
+/// decision 1821. `None` is silence the reference chose, not a hole in the table.
+///
+/// **The `default` arm is a real message, not an invented fallback**: every code outside the
+/// table — 0, 3, 9, 10 and everything from 13 up — shows `ERR_ITEM_NOT_FOUND` (`0x5dce81`, the
+/// `ja` target of `cmp eax,0xc`).
+fn buy_error_key(reason: u8) -> Option<&'static str> {
+ Some(match reason {
+ // One arm (`0x5dcdee`) for both: vmangos calls 1 ALREADY_SOLD and 7 SOLD_OUT, and the
+ // reference tells them apart only by code 1 also zeroing the row
+ // ([`crate::net::apply::npc::vendor_buy_failed`]).
+ buy_result::ITEM_ALREADY_SOLD | buy_result::ITEM_SOLD_OUT => "ERR_VENDOR_SOLD_OUT", // 0x23
+ buy_result::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
+ buy_result::SELLER_DONT_LIKE_YOU => "ERR_VENDOR_HATES_YOU", // 0x22
+ buy_result::DISTANCE_TOO_FAR => "ERR_VENDOR_TOO_FAR", // 0x24
+ BUY_SILENT => return None,
+ buy_result::CANT_CARRY_MORE => "ERR_ITEM_MAX_COUNT", // 0x12 — speaks, line 0x1e
+ buy_result::RANK_REQUIRE => "ERR_CANT_EQUIP_RANK", // 0x05
+ buy_result::REPUTATION_REQUIRE => "ERR_CANT_EQUIP_REPUTATION", // 0x06
+ _ => "ERR_ITEM_NOT_FOUND", // 0x17 — the switch's own default
+ })
}
-/// The client's message string for a `SellResult` refusal (`SMSG_SELL_ITEM`'s error path); values
-/// from [`sell_result`] (vmangos `ItemDefines.h`).
-fn sell_error_text(reason: u8) -> String {
- match reason {
- sell_result::CANT_FIND_ITEM => "That item does not exist.".into(),
- sell_result::CANT_SELL_ITEM => "You can't sell that item.".into(),
- sell_result::CANT_FIND_VENDOR => "That vendor can't be found.".into(),
- sell_result::YOU_DONT_OWN_THAT_ITEM => "You don't own that item.".into(),
- sell_result::ONLY_EMPTY_BAG => "You can only sell an empty bag.".into(),
- other => format!("Sale failed ({other})."),
- }
+/// The GlobalStrings key a `SellResult` refusal (`SMSG_SELL_ITEM`'s error path) shows — the
+/// reference's switch at `0x5dd22c` (decision 1821).
+///
+/// **Silence is the default here**, the opposite of [`buy_error_key`]: code 0 returns before the
+/// switch (`0x5dd21e`), and 5 and everything from 7 up jump past the `DisplayError` to the
+/// handler's tail. vmangos's own header agrees line for line — its comment on `SELL_ERR_UNK = 5`
+/// is *"nothing appears…"*, and its comments on 2 and 3 (*"merchant doesn't like that item"* /
+/// *"merchant doesn't like you"*) name the two strings the reference picks, which its `CANT_SELL`
+/// / `CANT_FIND_VENDOR` identifiers do not.
+fn sell_error_key(reason: u8) -> Option<&'static str> {
+ Some(match reason {
+ sell_result::CANT_FIND_ITEM => "ERR_ITEM_NOT_FOUND", // 0x17
+ sell_result::CANT_SELL_ITEM => "ERR_VENDOR_NOT_INTERESTED", // 0x21
+ sell_result::CANT_FIND_VENDOR => "ERR_VENDOR_HATES_YOU", // 0x22
+ sell_result::YOU_DONT_OWN_THAT_ITEM => "ERR_NOT_OWNER", // 0x1b
+ sell_result::ONLY_EMPTY_BAG => "ERR_DESTROY_NONEMPTY_BAG", // 0x0b
+ _ => return None,
+ })
}
/// Resolve one wire [`VendorItem`] into the Lua-facing [`MerchantItem`]: the icon comes straight from
@@ -421,6 +449,7 @@ fn feed_merchant(
mut last_money: Local>>,
mut last_name: Local>>,
mut last_vendor: Local>>,
+ mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
@@ -429,14 +458,21 @@ fn feed_merchant(
let last_money = last_money.get(&script);
let last_name = last_name.get(&script);
let last_vendor = last_vendor.get(&script);
- // Refusals surface as the client's red error line (the equip/cast path's exact shape).
- for refusal in errors.0.drain(..) {
- let text = match refusal {
- MerchantRefusal::Buy(r) => buy_error_text(r),
- MerchantRefusal::Sell(r) => sell_error_text(r),
- };
- script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
- }
+ // Refusals go to the surface — and the voice — their message record names (decision 1815):
+ // the vendor's "not enough money" carries error-speech line 0x28, "you can't carry any more"
+ // line 0x1e. A code the reference says nothing for resolves to no key and reaches nothing.
+ let refusals: Vec<_> = errors
+ .0
+ .drain(..)
+ .filter_map(|refusal| {
+ let key = match refusal {
+ MerchantRefusal::Buy(r) => buy_error_key(r)?,
+ MerchantRefusal::Sell(r) => sell_error_key(r)?,
+ };
+ crate::ui_action::keyed_line(&script, key)
+ })
+ .collect();
+ crate::ui_action::show_messages(&mut script, &mut sink, "ui_merchant", refusals);
// The purse: push it only when it changes (u64 copper straight from PLAYER_FIELD_COINAGE),
// and fire the real client's PLAYER_MONEY event with it — the money displays (bag + merchant
// purses) repaint on the event rather than riding some window-content repaint that happens to
@@ -659,6 +695,98 @@ mod tests {
}
}
+ /// **The two refusal tables, welded to the message ids they were read from** (decision 1821).
+ /// Asserting the key alone would let a plausible-looking rename through; asserting the id it
+ /// resolves to is asserting the `push ; call 0x496720` that was actually disassembled.
+ #[test]
+ fn the_refusal_tables_are_the_references_own() {
+ let id = |key: &str| {
+ benilla_ui::messages::by_key(key)
+ .unwrap_or_else(|| panic!("{key} is not a catalog row"))
+ .id
+ };
+ // `SMSG_BUY_FAILED` — the switch at `0x5dcdd8`. Note 1 and 7 share `0x23`, 6 is silent,
+ // and the arms outside the table fall to `0x17` rather than to nothing.
+ for (code, want) in [
+ (0u8, Some(0x17u16)),
+ (1, Some(0x23)),
+ (2, Some(0x25)),
+ (3, Some(0x17)),
+ (4, Some(0x22)),
+ (5, Some(0x24)),
+ (6, None),
+ (7, Some(0x23)),
+ (8, Some(0x12)),
+ (9, Some(0x17)),
+ (10, Some(0x17)),
+ (11, Some(0x05)),
+ (12, Some(0x06)),
+ (13, Some(0x17)),
+ (255, Some(0x17)),
+ ] {
+ assert_eq!(buy_error_key(code).map(id), want, "buy code {code}");
+ }
+ // `SMSG_SELL_ITEM` — the switch at `0x5dd22c`, where the default is silence.
+ for (code, want) in [
+ (0u8, None),
+ (1, Some(0x17u16)),
+ (2, Some(0x21)),
+ (3, Some(0x22)),
+ (4, Some(0x1b)),
+ (5, None),
+ (6, Some(0x0b)),
+ (7, None),
+ (255, None),
+ ] {
+ assert_eq!(sell_error_key(code).map(id), want, "sell code {code}");
+ }
+ }
+
+ /// The two vendor refusals that **speak** — the reason the keys had to replace the hand-written
+ /// English at all (decision 1815's join). Everything else in the two tables is a silent row.
+ #[test]
+ fn the_purse_refusals_carry_their_voice_lines() {
+ let tag = |key: &str| benilla_ui::messages::by_key(key).expect("row").type_tag;
+ assert_eq!(tag("ERR_NOT_ENOUGH_MONEY"), 0x28);
+ assert_eq!(tag("ERR_ITEM_MAX_COUNT"), 0x1e);
+ for key in [
+ "ERR_VENDOR_SOLD_OUT",
+ "ERR_VENDOR_HATES_YOU",
+ "ERR_VENDOR_TOO_FAR",
+ "ERR_ITEM_NOT_FOUND",
+ "ERR_VENDOR_NOT_INTERESTED",
+ "ERR_NOT_OWNER",
+ "ERR_DESTROY_NONEMPTY_BAG",
+ "ERR_CANT_EQUIP_RANK",
+ "ERR_CANT_EQUIP_REPUTATION",
+ ] {
+ assert_eq!(tag(key), 0x44, "{key}");
+ }
+ }
+
+ /// `ITEM_ALREADY_SOLD` zeroes the row it refused, and only that row — the reference's own
+ /// cache write (`0x5dcdbf`), keyed by entry because vmangos puts the entry in that field.
+ #[test]
+ fn a_sold_out_refusal_zeroes_only_that_rows_count() {
+ let mut open = MerchantOpen::default();
+ open.open(7, vec![row(11, 1, 3), row(22, 2, 5), row(11, 3, 2)]);
+ open.sold_out(11);
+ assert_eq!(open.items[0].current_count, 0);
+ assert_eq!(
+ open.items[1].current_count, 5,
+ "a different entry is untouched"
+ );
+ assert_eq!(
+ open.items[2].current_count, 0,
+ "the reference does not stop at the first match"
+ );
+ open.sold_out(999);
+ assert_eq!(
+ open.items[1].current_count, 5,
+ "an entry we do not stock is a no-op"
+ );
+ }
+
#[test]
fn entry_at_maps_the_one_based_row() {
let mut open = MerchantOpen::default();
diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs
index 2f75a12b..ba8ba697 100644
--- a/crates/benilla-app/src/ui_script/chat_tests.rs
+++ b/crates/benilla-app/src/ui_script/chat_tests.rs
@@ -603,8 +603,12 @@ fn get_chat_window_info_shown_matches_the_shipped_frames() {
for i in 1..=7 {
let agrees: bool = s
.eval(&format!(
+ // Both sides are normalised to a boolean before comparing: `IsShown` answers the
+ // NUMBER 1 or nil (1830), so comparing it straight against `shown ~= nil` compares
+ // a number with a boolean and is false for every window. This test is about the two
+ // AGREEING, not about either one's shape.
"local _, _, _, _, _, _, shown = GetChatWindowInfo({i})\n\
- return (shown ~= nil) == ChatFrame{i}:IsShown()"
+ return (shown ~= nil) == (ChatFrame{i}:IsShown() ~= nil)"
))
.unwrap();
assert!(
diff --git a/crates/benilla-app/src/ui_script/guild_tests.rs b/crates/benilla-app/src/ui_script/guild_tests.rs
index 5a3befac..547dc4fe 100644
--- a/crates/benilla-app/src/ui_script/guild_tests.rs
+++ b/crates/benilla-app/src/ui_script/guild_tests.rs
@@ -743,7 +743,9 @@ fn the_rank_editor_loads_its_flags_and_arms_accept_on_an_edit() {
s.run("GuildControlPopupFrameCheckbox5:Click()").unwrap();
assert_eq!(
calls(&s),
- "GuildControlSetRankFlag:5:true",
+ // The Lua hands `this:GetChecked()` straight through, and in 1.12 that is the NUMBER 1
+ // (1830) — so 1 is what the reference's own call carries too.
+ "GuildControlSetRankFlag:5:1",
"the checkbox's own ID is what reaches the engine"
);
assert!(enabled(&s, "GuildControlPopupAcceptButton"));
diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs
index d5492a6c..b91a79f5 100644
--- a/crates/benilla-app/src/ui_script/reference_ui.rs
+++ b/crates/benilla-app/src/ui_script/reference_ui.rs
@@ -1432,20 +1432,6 @@ mod tests {
update; our `inventory_alerts` snapshot is recomputed on every inventory push \
instead, so the recompute exists and only the Lua verb that forces one does not.",
),
- (
- "QuestTimerFrame.xml",
- "QuestLog_SetSelection",
- "a FrameXML function, declared in stock `QuestLogFrame.lua:308` — which we do not \
- source, because our own `QuestLogFrame.xml` is that window. Clicking a quest \
- TIMER raises (`QuestTimerFrame.lua:43`). It arrives with the quest log's own \
- migration and not before.",
- ),
- (
- "QuestTimerFrame.xml",
- "QuestLog_Update",
- "the twin of the line above, `QuestLogFrame.lua:107`, called on the next line \
- (`QuestTimerFrame.lua:44`).",
- ),
];
let toc = &super::super::addons::Addon::builtin().toc.files;
diff --git a/crates/benilla-app/src/ui_script/taxi_tests.rs b/crates/benilla-app/src/ui_script/taxi_tests.rs
index 0e50f667..f5e98c31 100644
--- a/crates/benilla-app/src/ui_script/taxi_tests.rs
+++ b/crates/benilla-app/src/ui_script/taxi_tests.rs
@@ -19,10 +19,30 @@ fn taxi_script() -> UiScript {
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal
- load_xml(&s, "TaxiFrame.xml");
+ // Three the reference's own TaxiFrame leans on that our transcription did not:
+ // · GlobalStrings — `ERR_TAXINOPATHS` is a GlobalString, and `AddMessage(nil)` draws an
+ // empty line rather than raising, so its absence is silent.
+ // · UIPanelTemplates (.lua then .xml) — `TaxiCloseButton` inherits `UIPanelCloseButton`,
+ // which lives there and NOT in our UiPanels.xml. Without it the close button loads as a
+ // bare Button with no handler and a click does nothing at all.
+ load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
+ load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
+ load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
+ load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml");
s
}
+/// Seat the flight master as the `"npc"` unit. Stock `TaxiFrame_OnEvent` fills its label from
+/// `UnitName("npc")` (TaxiFrame.lua:27), where our deleted transcription read the `TAXIMAP_OPENED`
+/// argument — so a fixture that only fires the event leaves the reference's label blank.
+fn seat_flight_master(s: &mut UiScript, name: &str) {
+ let npc = benilla_ui::script::UnitState {
+ name: Some(name.into()),
+ ..Default::default()
+ };
+ s.set_unit("npc", Some(npc));
+}
+
/// A two-node snapshot: Stormwind (Current) and the verified Sentinel Hill hop (Reachable, 110
/// copper, one route segment) — [`crate::ui_taxi::build_nodes`]'s exact real-data shape.
fn menu() -> TaxiUiState {
@@ -59,6 +79,7 @@ fn shipped_taxi_frame_drives_end_to_end() {
assert!(!s.eval::("return TaxiFrame:IsVisible()").unwrap());
s.set_taxi(Some(menu()));
+ seat_flight_master(&mut s, "Dungar Longdrink");
s.fire_event(
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
@@ -67,15 +88,22 @@ fn shipped_taxi_frame_drives_end_to_end() {
assert!(s.eval::("return TaxiFrame:IsVisible()").unwrap());
assert_eq!(
- s.eval::("return TaxiNameText:GetText()").unwrap(),
+ // The reference names the flight-master label `TaxiMerchant` and fills it from
+ // `UnitName("npc")` (TaxiFrame.lua:27) — NOT from the event's argument, which is what our
+ // transcription's `TaxiNameText` read. So the fixture seats the npc unit above.
+ s.eval::("return TaxiMerchant:GetText()").unwrap(),
"Dungar Longdrink"
);
- // The two pushed nodes show; the pool's remainder (slot 3 onward) stays hidden.
+ // The two pushed nodes show, and there is no third button AT ALL — the reference builds its
+ // node buttons on demand, one `CreateFrame("Button", "TaxiButton"..i, TaxiRouteMap,
+ // "TaxiButtonTemplate")` per node (TaxiFrame.lua:39), where our transcription pre-made a fixed
+ // pool and hid the tail. Same thing on screen, a different mechanism behind it — so the
+ // expectation moves from "hidden" to "absent".
assert!(s.eval::("return TaxiButton1:IsVisible()").unwrap());
assert!(s.eval::("return TaxiButton2:IsVisible()").unwrap());
- assert!(!s.eval::("return TaxiButton3:IsVisible()").unwrap());
- assert!(!s.eval::("return TaxiButton50:IsVisible()").unwrap());
+ assert!(s.eval::("return TaxiButton3 == nil").unwrap());
+ assert!(s.eval::("return TaxiButton50 == nil").unwrap());
// A click on node 2 (Sentinel Hill) drains through TakeTaxiNode.
s.resolve();
@@ -113,6 +141,7 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
routes: vec![],
}],
}));
+ seat_flight_master(&mut s, "Dungar Longdrink");
s.fire_event(
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
@@ -142,13 +171,17 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
fn close_button_queues_the_intent_and_hides() {
let mut s = taxi_script();
s.set_taxi(Some(menu()));
+ seat_flight_master(&mut s, "Dungar Longdrink");
s.fire_event(
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
);
assert!(s.eval::("return TaxiFrame:IsVisible()").unwrap());
- s.run("BenillaTaxiCloseButton_OnClick()").unwrap();
+ // The reference's close button is a plain `UIPanelCloseButton` (TaxiFrame.xml:133) — it has no
+ // handler of its own, so the click goes through the template's, which hides the parent panel.
+ // Ours carried a named `BenillaTaxiCloseButton_OnClick`; that went with the file.
+ s.run("TaxiCloseButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.take_taxi_close());
assert!(!s.eval::("return TaxiFrame:IsVisible()").unwrap());
diff --git a/crates/benilla-app/tests/message_keys.rs b/crates/benilla-app/tests/message_keys.rs
index 5a7afcef..9ea22004 100644
--- a/crates/benilla-app/tests/message_keys.rs
+++ b/crates/benilla-app/tests/message_keys.rs
@@ -81,6 +81,65 @@ fn every_error_key_in_the_source_is_a_catalog_row() {
);
}
+/// **The claim this file's own doc has always made, finally checked** (decision 1821): a key the
+/// source names must not only be a catalog row, it must **resolve to text**.
+///
+/// The two are not the same, and the difference is the failure this file exists to catch. A row's
+/// `key` is a `GlobalStrings.lua` *lookup*, and the shipped file has no entry for a good many of
+/// them — that absence is the reference's own data-suppression face, and a raise site that picks
+/// one of those rows is silent for good. The walk above would pass it.
+///
+/// Worth closing now because 1821 moved four more windows' refusals onto keys (the vendor's two
+/// tables, the banker's, the mailbox's), where a superseded or mistyped key costs a whole window's
+/// worth of lines rather than one toast. Skips without client data.
+#[test]
+fn every_error_key_in_the_source_resolves_to_real_text() {
+ /// Keys whose row the shipped `GlobalStrings.lua` has **no string for**, so the reference
+ /// itself shows nothing when it raises them. Both are documented where they are raised —
+ /// `ui_items::equip_error` (errorId 362) and `ui_action::cast_fail` (the pet-happiness arm).
+ /// A third entry here is a defect until someone proves otherwise.
+ const SILENT_IN_5875: &[&str] = &["ERR_CANT_BE_DISENCHANTED", "ERR_NOT_HAPPY_ENOUGH"];
+
+ let data = match benilla_formats::wow_data() {
+ Some(d) => d,
+ None => return,
+ };
+ let mut chain = benilla_formats::open_chain(&data).expect("open chain");
+ let src = chain
+ .read_file("Interface\\FrameXML\\GlobalStrings.lua")
+ .expect("GlobalStrings.lua in the chain");
+ let vm = benilla_ui::script::UiScript::new().expect("VM");
+ vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
+
+ let mut sources = Vec::new();
+ walk(Path::new("src"), &mut sources);
+ walk(Path::new("../benilla-ui/src"), &mut sources);
+ let keys: BTreeSet = sources.iter().flat_map(|s| err_keys(s)).collect();
+
+ let mut resolved = 0;
+ let silent: Vec<&String> = keys
+ .iter()
+ .filter(|k| !NOT_A_MESSAGE.contains(&k.as_str()))
+ .filter(|k| !SILENT_IN_5875.contains(&k.as_str()))
+ .filter(|k| {
+ let text: String = vm.lua().globals().get(k.as_str()).unwrap_or_default();
+ resolved += usize::from(!text.is_empty());
+ text.is_empty()
+ })
+ .collect();
+ assert!(
+ silent.is_empty(),
+ "these keys are catalog rows but resolve to NOTHING in the shipped GlobalStrings.lua, so \
+ every line raised through them is invisible: {silent:?}"
+ );
+ // A floor, not a ledger: the point is that the VM really loaded the file, so an empty `vm`
+ // cannot make the sweep above vacuously green.
+ assert!(
+ resolved > 150,
+ "only {resolved} keys resolved — did GlobalStrings load?"
+ );
+}
+
/// **The other half of the same claim, on real data: a key that carries a VOICE line can actually
/// be spoken** (decision 1815).
///
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 663e2a31..db9859a5 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -345,9 +345,13 @@ impl MaterialExtension for WowModelExt {
// The nudge now lives in `wow_model.wgsl`'s vertex stage, an exact relative scale of clip z
// driven by `sun_scale.y` (uniform DATA, no pipeline axis) — same one-ULP-per-index
// semantics, byte-verified intent unchanged (wow-5875-re wmo-batch-blend-depth-state.md).
- if key.bind_group_data.fade {
+ if key.bind_group_data.fade && !key.bind_group_data.sky_depth {
// The distance-fade blend twin needs depth-write ON so near geometry occludes far within the
- // same fading model — force it regardless of the per-flag rule above.
+ // same fading model — force it regardless of the per-flag rule above. EXCEPT on the
+ // WMO-skybox lane: its twin exists for the 4-second crossfade, where every fragment
+ // forces the one far depth — there is no within-model occlusion to preserve, and the
+ // sky's own law is depth-write off, always (the fragment leaves the z-buffer at its
+ // clear value so the world and the forced-far glare quads order by depth alone).
if let Some(ds) = descriptor.depth_stencil.as_mut() {
ds.depth_write_enabled = true;
}
diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs
index c2a1d0b8..04d14f32 100644
--- a/crates/benilla-ui/src/loader/tests.rs
+++ b/crates/benilla-ui/src/loader/tests.rs
@@ -830,7 +830,7 @@ mod loader_tests {
return XmlBtn:GetText() == "Push Me"
and XmlBtn:GetNormalTexture() ~= nil
and XmlBtn:GetPushedTexture() ~= nil
- and XmlCheck:GetChecked() == true
+ and XmlCheck:GetChecked() == 1
"#,
)
.unwrap();
diff --git a/crates/benilla-ui/src/messages.rs b/crates/benilla-ui/src/messages.rs
index 24459837..209a986e 100644
--- a/crates/benilla-ui/src/messages.rs
+++ b/crates/benilla-ui/src/messages.rs
@@ -68,11 +68,11 @@ pub struct MessageRecord {
pub key: &'static str,
/// Where it is shown.
pub kind: MsgKind,
- /// The sound cue played with it, or `None` for the 435 silent rows. Carried as data;
- /// benilla does not wire message cues yet.
+ /// The sound cue played with it, or `None` for the 435 silent rows. Read at the display, by
+ /// `benilla_app::sound::message` (decision 1815).
pub sound: Option<&'static str>,
- /// `+0x0c`. `0x44` means "play [`Self::sound`]"; the other 56 rows carry an error-speech id
- /// instead. Unmodelled, recorded.
+ /// `+0x0c`. `0x44` means "play [`Self::sound`]"; the other 56 rows carry an error-speech line
+ /// id instead, spoken in the player's own race and gender voice (decision 1815).
pub type_tag: u8,
/// The chat type handed to the composer, meaningful for [`MsgKind::Chat`] rows only. `10`
/// (`CHAT_MSG_SYSTEM`) for all but three — the skill-up trio `ERR_PROFICIENCY_GAINED_S`,
diff --git a/crates/benilla-ui/src/script/binding_abi.rs b/crates/benilla-ui/src/script/binding_abi.rs
index 3e03380b..3d0c4ce3 100644
--- a/crates/benilla-ui/src/script/binding_abi.rs
+++ b/crates/benilla-ui/src/script/binding_abi.rs
@@ -163,6 +163,36 @@ pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option {
/// *strings* `"0"`/`"1"`, and a re-implementation wired to `lua_toboolean` inverts every
/// string-valued option in the game. Equally, `"0.5"` and `"-1"` are decided by their first byte
/// alone — false and `default` — never by their numeric value.
+/// A widget **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean.
+///
+/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12 widget
+/// bindings do not push booleans. `lua_pushboolean 0x6f39f0` has seven call sites in the whole
+/// image and not one of them is inside a widget registrar body — every predicate goes through
+/// `lua_pushnumber`/`lua_pushnil`. Decision 1830.
+///
+/// **Truthiness hides the difference and direct comparison inverts it**, which is why this survived
+/// so long. `if frame:IsVisible()` reads the same either way; `if frame:IsVisible() == nil` does
+/// not — under a boolean a hidden frame answers `false`, and `false == nil` is FALSE, so the caller
+/// concludes the frame is visible exactly when it is not. The 1.12 addon corpus has 21 such direct
+/// comparisons (`IsVisible() == nil` ×9, `GetChecked() == 1` ×6, `IsVisible() ~= nil` ×4, and one
+/// each of `~= 1` / `== 1`) across Questie, AtlasQuest, CT_BagMod, MikScrollingBattleText's options
+/// and `_dl`.
+///
+/// The reference proves its own shape without needing the bytes: stock `UIOptionsFrame.xml:310`
+/// saves a checkbox as `SHOW_BUFF_DURATIONS = tostring(this:GetChecked())` and stock
+/// `BuffFrame.lua:71` reads it back as `== "1"`. That round-trip only closes if `GetChecked`
+/// returns the number 1 — `tostring(true)` is `"true"`, and buff timers would never appear.
+///
+/// Adopting it is strictly safer than what it replaces: every `if x` and `not x` site reads
+/// identically, and only the direct comparisons change — from wrong to right.
+pub(crate) fn predicate(b: bool) -> Value {
+ if b {
+ Value::Integer(1)
+ } else {
+ Value::Nil
+ }
+}
+
pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
let Some(v) = v else {
return default; // LUA_TNONE
diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs
index 077b7d31..cd25b925 100644
--- a/crates/benilla-ui/src/script/button.rs
+++ b/crates/benilla-ui/src/script/button.rs
@@ -816,8 +816,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
c.set(
+ // GetChecked() -> the NUMBER 1 or nil, never a boolean (1830). Its neighbour
+ // `IsEnabled` two entries up was corrected for the same reason under 1719, whose comment
+ // records that a boolean here killed pfUI's widget module outright; this is that call
+ // finished. The reference proves the shape in its own source rather than only at the
+ // bytes: stock `UIOptionsFrame.xml:310` saves a checkbox as
+ // `tostring(this:GetChecked())` and stock `BuffFrame.lua:71` reads it back as `== "1"`,
+ // a round-trip that only closes on the number.
"GetChecked",
- lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.checked))?,
+ lua.create_function(|lua, this: Table| {
+ let checked = with_button(lua, &this, |bs| bs.checked)?;
+ Ok(crate::script::binding_abi::predicate(checked))
+ })?,
)?;
lua.set_named_registry_value(REG_CHECKBUTTON_METHODS, c)?;
diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs
index 64b243d9..78dcb1d2 100644
--- a/crates/benilla-ui/src/script/object.rs
+++ b/crates/benilla-ui/src/script/object.rs
@@ -203,6 +203,24 @@ pub fn frame_kind_from_tag(s: &str) -> Option {
fn frame_kind_from_str(s: &str) -> Option {
Some(match enum_token(s).as_str() {
"FRAME" => FrameKind::Frame,
+ // `TaxiRouteFrame` — a registered `CreateFrame` type that is a `CSimpleFrame` and NOTHING
+ // else, so it maps to `Frame` rather than earning a kind (decision 1828; wow-re
+ // `ui/scratch/taxiroute-widget-type.md`). Factory `0x495ba0` allocates `0x314`, the same
+ // size the plain-`` factory `0x6eec10` allocates for the same base ctor `0x769090`,
+ // and the ctor `0x506950` adds no field. Its vtables are the base's length exactly (36 + 11
+ // slots), so it declares no new virtual; it overrides four slots, of which two are lifetime
+ // plumbing and the other two — `EmitLayer 0x506a60` and a scale hook `0x506ac0` — are DEAD
+ // in 5875: the paint callback `0x506a90` tail-calls `0x506aa0`, which is a bare `ret`, and
+ // every module global it would fill is written by nothing.
+ //
+ // Mapping it to `Frame` is therefore the faithful answer, not a 1203 shortcut — and it is
+ // faithful in the strong sense that `GetObjectType()` must return **"Frame"** and
+ // `IsObjectType("TaxiRouteFrame")` must be **false**: `0x484800` is not overridden, so the
+ // name lives only in the 22-entry tag→factory registry and never becomes a class identity.
+ // The flight-path lines are plain `` children the FrameXML creates and rotates
+ // with the 8-argument `SetTexCoord` we already support; `TaxiRouteFrame.cpp` is a vestigial
+ // C++ renderer that draws nothing.
+ "TAXIROUTEFRAME" => FrameKind::Frame,
"BUTTON" => FrameKind::Button,
"CHECKBUTTON" => FrameKind::CheckButton,
// A real registered type (`0x4959a6`), not an alias for Button — decision 1799. Registering
diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs
index b7caa5e9..e77230d6 100644
--- a/crates/benilla-ui/src/script/object/frame_state.rs
+++ b/crates/benilla-ui/src/script/object/frame_state.rs
@@ -37,7 +37,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.frame(h).map(|f| f.shown).unwrap_or(false))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.frame(h).map(|f| f.shown).unwrap_or(false),
+ ))
})?,
)?;
m.set(
@@ -45,11 +47,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model
- .arena
- .frame(h)
- .map(|f| f.effective_visible)
- .unwrap_or(false))
+ Ok(crate::script::binding_abi::predicate(
+ model
+ .arena
+ .frame(h)
+ .map(|f| f.effective_visible)
+ .unwrap_or(false),
+ ))
})?,
)?;
// Identity / hierarchy
@@ -669,7 +673,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.is_mouse_enabled(h))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.is_mouse_enabled(h),
+ ))
})?,
)?;
// EnableKeyboard(flag) / IsKeyboardEnabled() — `0x776f90` / `0x776ff0`, real Frame entries in
@@ -703,7 +709,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.is_keyboard_enabled(h))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.is_keyboard_enabled(h),
+ ))
})?,
)?;
// `EnableMouseWheel(flag)` / `IsMouseWheelEnabled()` — the wheel's own gate, a separate flag
@@ -769,7 +777,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.is_mouse_wheel_enabled(h))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.is_mouse_wheel_enabled(h),
+ ))
})?,
)?;
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit4 — layout.md): the layout resolve
@@ -793,7 +803,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.is_clamped_to_screen(h))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.is_clamped_to_screen(h),
+ ))
})?,
)?;
// Hit-rect insets — the MOUSE rect only (widget::Frame::hit_rect_insets): the hit test shrinks
diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs
index 671308b1..353966ae 100644
--- a/crates/benilla-ui/src/script/object/movable.rs
+++ b/crates/benilla-ui/src/script/object/movable.rs
@@ -404,14 +404,18 @@ fn set_flag(lua: &Lua, this: &Table, which: Flag, value: bool) -> mlua::Result<(
Ok(())
}
-fn get_flag(lua: &Lua, this: &Table, which: Flag) -> mlua::Result {
+/// `IsMovable`/`IsResizable`/`IsUserPlaced` — the NUMBER 1 or nil, never a boolean (1830,
+/// [`crate::script::binding_abi::predicate`]).
+fn get_flag(lua: &Lua, this: &Table, which: Flag) -> mlua::Result {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.frame(h).is_some_and(|f| match which {
- Flag::Movable => f.movable,
- Flag::Resizable => f.resizable,
- Flag::UserPlaced => f.user_placed,
- }))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.frame(h).is_some_and(|f| match which {
+ Flag::Movable => f.movable,
+ Flag::Resizable => f.resizable,
+ Flag::UserPlaced => f.user_placed,
+ }),
+ ))
}
/// The family's refusal, named frame and all — the reference's own shape (it formats the frame's
diff --git a/crates/benilla-ui/src/script/object/toplevel.rs b/crates/benilla-ui/src/script/object/toplevel.rs
index d215fcfd..96feff63 100644
--- a/crates/benilla-ui/src/script/object/toplevel.rs
+++ b/crates/benilla-ui/src/script/object/toplevel.rs
@@ -125,7 +125,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::().expect("model");
- Ok(model.arena.is_toplevel(h))
+ Ok(crate::script::binding_abi::predicate(
+ model.arena.is_toplevel(h),
+ ))
})?,
)?;
// Raise() — `0x775a50` → `0x76a5b0` → `0x7650f0(this, force = 1)`. Note it is legal on any
diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs
index 2ca05506..f1b1e080 100644
--- a/crates/benilla-ui/src/script/tests/button.rs
+++ b/crates/benilla-ui/src/script/tests/button.rs
@@ -220,7 +220,8 @@ fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() {
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::("return clicks").unwrap(), 1);
- assert!(s.eval::("return seen_checked == true").unwrap());
+ // `seen_checked` is whatever `GetChecked()` handed the handler — the NUMBER 1 (1830).
+ assert!(s.eval::("return seen_checked == 1").unwrap());
assert!(s.eval::("return Toggler:GetChecked()").unwrap());
// Programmatic Click() rides the same path: toggles back off.
diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs
index b976b9e7..0ab90a80 100644
--- a/crates/benilla-ui/src/script/tests/input.rs
+++ b/crates/benilla-ui/src/script/tests/input.rs
@@ -17,7 +17,9 @@ fn enable_mouse_gates_hit_testing() {
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(false)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
- assert(a:IsMouseEnabled() == true and b:IsMouseEnabled() == false)
+ -- 1/nil, not a boolean (1830): this is the exact comparison shape that inverts, so the
+ -- test asserts it rather than leaning on truthiness.
+ assert(a:IsMouseEnabled() == 1 and b:IsMouseEnabled() == nil)
"#,
)
.unwrap();
diff --git a/crates/benilla-world/src/clouds/layer.rs b/crates/benilla-world/src/clouds/layer.rs
index a4f57f32..6ffa84ff 100644
--- a/crates/benilla-world/src/clouds/layer.rs
+++ b/crates/benilla-world/src/clouds/layer.rs
@@ -181,23 +181,27 @@ pub(super) fn setup_cloud_layer(
/// A WMO skybox ([`crate::skybox`]) hides the clouds too: `CSky::Render`'s one shared boolean skips
/// **all six** sky elements together (`0x6d4a3b test edi,edi; je`), so the painted art is the whole
/// sky rather than a backdrop the procedural clouds keep drawing over. The art carries its own cloud
-/// banks; layering ours on top lifted the painted zenith out of its near-black.
+/// banks; layering ours on top lifted the painted zenith out of its near-black. The gate is the
+/// slot's **weight** past 0.99 ([`crate::skybox::SkyboxWeight`]): through the body of the 4-second
+/// crossfade the clouds keep drawing and the painted sky alpha-blends over them.
///
/// A **submerged eye** hides them the same way: the scene driver's `0x6812a4` submerged test skips
/// the whole `CSky::Render` call (byte-VERIFIED, wow-re terrain "the liquid render state") — from
/// under the surface there is no sky, only the murk.
pub(super) fn apply_cloud_visibility(
debug: Res,
- skybox: Res,
+ skybox: Res,
underwater: Res,
mut dome: Query<&mut Visibility, With>,
) {
if let Ok(mut vis) = dome.single_mut() {
- *vis = if debug.lighting.disable_sky_dome || skybox.0.is_some() || underwater.0.any() {
- Visibility::Hidden
- } else {
- Visibility::Inherited
- };
+ *vis =
+ if debug.lighting.disable_sky_dome || skybox.replaces_celestial() || underwater.0.any()
+ {
+ Visibility::Hidden
+ } else {
+ Visibility::Inherited
+ };
}
}
diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs
index 21827733..ff7da82e 100644
--- a/crates/benilla-world/src/lighting/mod.rs
+++ b/crates/benilla-world/src/lighting/mod.rs
@@ -23,6 +23,7 @@ pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index
// (see `blob`); `global_light` builds the world's own blob on the frame path.
pub(crate) use prop_probes::prop_probe_region_offset;
+pub use resolve::WmoCrossfade;
use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting};
pub(crate) use sh::prop_probe_coeffs;
@@ -52,7 +53,7 @@ pub struct WowLighting {
pub(crate) fog_start: f32,
pub(crate) fog_end: f32,
/// The **interior** fog triple (DNState+0x80/84/88): `lerp(scene fog → the claimed WMO's MFOG
- /// fog, t)` on the 4 s camera-in-WMO ramp ([`resolve::WmoFogRamp`]) — equal to the scene triple while
+ /// fog, t)` on the 4 s camera-in-WMO ramp ([`WmoCrossfade`]) — equal to the scene triple while
/// the camera is outside. Consumed ONLY by the interior lanes (round-6 Q-I consumer map): the
/// interior WMO-group surfaces and THAT group's doodads (`0x6b5190` / the group-doodad drawer
/// `0x6b62e0`) — `wow_model.wgsl` selects it by the material interior flag. Terrain, liquid,
@@ -279,6 +280,12 @@ pub struct GameClock {
pub source: ClockSource,
}
+/// Ordering handle: [`WowLighting`] and [`WmoCrossfade`] are resolved for the frame after this
+/// set. The skybox weight resolve hangs off it — the sky reading a stale crossfade is a frame
+/// where the painted sky and the fog disagree about how far into the building you are.
+#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct LightingResolveSet;
+
/// The lighting subsystem: registers the WoW-light resource + game-clock, a **black** background
/// (Phase 0 — the DBC sky comes back in a later step), and the two per-frame systems that resolve the
/// `Light.dbc` values for the time of day and push them onto the materials.
@@ -290,12 +297,14 @@ impl Plugin for LightingPlugin {
.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
.insert_resource(ClearColor(Color::BLACK))
.add_systems(Startup, setup_lighting.after(AssetSet::Open))
.add_systems(
Update,
(update_time_lighting, apply_sky_backdrop)
.chain()
+ .in_set(LightingResolveSet)
// The storm blend reads this frame's weather densities (decision 0302).
.after(crate::weather::WeatherTick)
// …and this frame's game clock, which whatever owns the session publishes in
diff --git a/crates/benilla-world/src/lighting/resolve.rs b/crates/benilla-world/src/lighting/resolve.rs
index 0f864c73..529ab8a6 100644
--- a/crates/benilla-world/src/lighting/resolve.rs
+++ b/crates/benilla-world/src/lighting/resolve.rs
@@ -1,6 +1,6 @@
//! The per-frame time-of-day RESOLVE: samples `Light.dbc` against the effective game clock (server
//! clock, manual debug-panel scrub, or the pre-connect noon fallback) into a fresh [`super::WowLighting`]
-//! — the scene-fog stage, the camera-in-WMO interior-fog crossfade ([`WmoFogRamp`]), and the startup
+//! — the scene-fog stage, the camera-in-WMO interior-fog crossfade ([`WmoCrossfade`]), and the startup
//! seed ([`setup_lighting`]) all live here. Split from the resource type + plugin registration in
//! `super`.
@@ -31,22 +31,31 @@ fn scene_fog(fog_end_raw: f32, fog_start_frac: f32, farclip: f32) -> (f32, f32)
/// out (`0x6cefcb–0x6cf051`, wow-re `rf-weather-emission-timeline` ROUND 5).
const WMO_FOG_RAMP_PER_SEC: f32 = 0.25;
-/// The camera-in-WMO **interior fog** blend (`[0xce9bdc]` + the staging `0x6cef43–62`): while the
-/// camera stands in a WMO interior, the scene fog — storm veil included — crossfades toward the
-/// building's own MFOG fog over 4 s, and back out over 4 s on leaving. This is why the reference's
-/// Goldshire inn keeps its warm authored haze while a storm rages outside (the storm's
-/// negative-start veil never reaches the room). The staged triple LATCHES while the camera exits so
-/// the fade-out lerps from the room's fog instead of popping. A mid-interior fog-record switch
-/// (tavern → corridor) snaps the staged target — the client's source-identity guard (`0x6cef92–b5`)
-/// re-arms a lerp there whose exact semantics are un-carved (flagged in 0338); at room scales both
-/// records' fog is ≈0 so the step is invisible.
-#[derive(Default)]
-pub(super) struct WmoFogRamp {
+/// The camera-in-WMO **interior crossfade** — the reference's `[0xce9bdc]` (+ the staging
+/// `0x6cef43–62`), ONE number with exactly two consumers: while the camera stands in a WMO
+/// interior, the scene fog — storm veil included — crossfades toward the building's own MFOG fog
+/// over 4 s and back out over 4 s on leaving, **and the same `t` is the WMO skybox's slot alpha**
+/// ("the skybox alpha and the interior fog blend are the same number" — wow-re `wmo-skybox.md` §3,
+/// VERIFIED; `crate::skybox` reads it through [`Self::t`], never a ramp of its own). This is why
+/// the reference's Goldshire inn keeps its warm authored haze while a storm rages outside (the
+/// storm's negative-start veil never reaches the room), and why Stratholme's painted sky fades in
+/// at exactly the rate its streets shed the scene fog. The staged triple LATCHES while the camera
+/// exits so the fade-out lerps from the room's fog instead of popping. A mid-interior fog-record
+/// switch (tavern → corridor) snaps the staged target — the client's source-identity guard
+/// (`0x6cef92–b5`) re-arms a lerp there whose exact semantics are un-carved (flagged in 0338); at
+/// room scales both records' fog is ≈0 so the step is invisible.
+#[derive(Resource, Default)]
+pub struct WmoCrossfade {
t: f32,
staged: Option,
}
-impl WmoFogRamp {
+impl WmoCrossfade {
+ /// The crossfade weight this frame, `[0, 1]` — advanced only by [`update_time_lighting`]'s
+ /// blend, read by the skybox weight resolve.
+ pub fn t(&self) -> f32 {
+ self.t
+ }
/// Advance the ramp by `dt` toward `target` and return the committed `(color, start, end)`.
/// The staging transform is the record's own law: `end = min(end, farclip)`,
/// `start = end × start_scalar` (`fmul 0x6cef56` — MFOG start IS a fraction of end in 1.12.1).
@@ -197,7 +206,7 @@ pub(super) fn update_time_lighting(
view: Res,
time: Res