diff --git a/crates/benilla-app/assets/ui/QuestFrame.xml b/crates/benilla-app/assets/ui/QuestFrame.xml
index ea108cd4..f553b537 100644
--- a/crates/benilla-app/assets/ui/QuestFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestFrame.xml
@@ -484,9 +484,12 @@
end
function QuestDetailDeclineButton_OnClick()
- -- vanilla's client-side decline sends no packet; hide immediately (no one-frame lag,
- -- the same convention every NPC window's close button already uses — e.g.
- -- BenillaMerchantCloseButton_OnClick).
+ -- DeclineQuest() DOES send (decision 1738, byte-verified — this comment used to claim
+ -- the opposite for years): the app's session-end routine re-opens the giver's list
+ -- (CMSG_QUESTGIVER_HELLO / CMSG_GOSSIP_HELLO), which is what puts you back in an NPC's
+ -- menu after a decline, or answers a party member's share. Either way the panel is
+ -- hidden here immediately rather than waiting on a packet — the same convention every
+ -- NPC window's close button already uses (e.g. BenillaMerchantCloseButton_OnClick).
DeclineQuest()
PlaySound("igQuestCancel") -- ref QuestDetailDeclineButton_OnClick, QuestFrame.lua:577
HideUIPanel(getglobal("QuestFrame"))
diff --git a/crates/benilla-app/assets/ui/QuestLogFrame.xml b/crates/benilla-app/assets/ui/QuestLogFrame.xml
index d12d4301..4ac711a8 100644
--- a/crates/benilla-app/assets/ui/QuestLogFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestLogFrame.xml
@@ -297,6 +297,15 @@
abandonButton:Enable()
end
+ -- Share Quest (ref QuestLog_Update's tail, QuestLogFrame.lua:299-305) — the predicate
+ -- verbatim, in the ref's own guard order: an EMPTY log disables outright, otherwise the
+ -- quest must be sharable AND we must be in a party. Both halves matter and neither is
+ -- redundant. GetQuestLogPushable() reads the SELECTION's QUEST_FLAGS_SHARABLE bit off
+ -- the template cache, which is the client's own test and the server's only reliance:
+ -- vmangos never re-checks the bit on the push, only on the receiver's accept, so a
+ -- button we wrongly lit would push a quest the party is then refused (decision 1733).
+ BenillaQuestLogPushButton_Update(numEntries)
+
-- Clamp the faux-scroll offset (pin §2's FauxScrollFrame_Update clamp).
local maxOffset = numEntries - QUESTS_DISPLAYED
if maxOffset < 0 then maxOffset = 0 end
@@ -1091,8 +1100,32 @@
end
end
+ -- The Share Quest button's enable state (ref QuestLogFrame.lua:299-305 and :74-79, which
+ -- are the same three lines twice: the full update runs it with the entry count to hand,
+ -- and PARTY_MEMBERS_CHANGED runs it alone because a party you just joined or left changes
+ -- the answer without changing the log). `numEntries` is optional — omitted, it is read
+ -- here, which is what the event path wants.
+ function BenillaQuestLogPushButton_Update(numEntries)
+ if not numEntries then
+ numEntries = GetNumQuestLogEntries()
+ end
+ local pushButton = getglobal("QuestLogPushQuestButton")
+ if numEntries == 0 then
+ pushButton:Disable()
+ elseif GetQuestLogPushable() and GetNumPartyMembers() > 0 then
+ pushButton:Enable()
+ else
+ pushButton:Disable()
+ end
+ end
+
function BenillaQuestLogFrame_OnLoad(frame)
frame:RegisterEvent("QUEST_LOG_UPDATE")
+ -- ref QuestLog_OnLoad (QuestLogFrame.lua:44-54) registers this for exactly one reason:
+ -- the Share Quest button's second conjunct. Joining or leaving a party changes whether
+ -- the selected quest can be shared without changing the quest log at all, so
+ -- QUEST_LOG_UPDATE alone would leave the button stale (decision 1733).
+ frame:RegisterEvent("PARTY_MEMBERS_CHANGED")
end
function BenillaQuestLogFrame_OnEvent(frame)
@@ -1101,6 +1134,11 @@
-- passes `1`) — a quest updating while already being read must not yank the reader's
-- scroll position back to the top.
BenillaQuestLogFrame_Update(true)
+ elseif event == "PARTY_MEMBERS_CHANGED" then
+ -- The ref runs a whole QuestLog_Update here (its else-branch, QuestLogFrame.lua:71-81)
+ -- and then re-tests the button; the list cannot have changed, so only the button is
+ -- repainted. Same observable state, no scroll or selection churn.
+ BenillaQuestLogPushButton_Update()
end
end
@@ -1813,14 +1851,24 @@
HideUIPanel(getglobal("QuestLogFrame"))
+ LEFT of Exit. Live since decision 1733; it starts DISABLED because that is the
+ solo/no-selection state the ref's own predicate produces, and
+ BenillaQuestLogPushButton_Update re-tests it on every list update and every
+ PARTY_MEMBERS_CHANGED. The newbie tooltip is the ref's (NEWBIE_TOOLTIP_SHAREQUEST,
+ GlobalStrings.lua:2764), through the same GameTooltip_AddNewbieTip the ref calls. -->
diff --git a/crates/benilla-app/assets/ui/QuestShareFrame.xml b/crates/benilla-app/assets/ui/QuestShareFrame.xml
new file mode 100644
index 00000000..2000a050
--- /dev/null
+++ b/crates/benilla-app/assets/ui/QuestShareFrame.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+ self:RegisterEvent("QUEST_ACCEPT_CONFIRM")
+ BenillaQuestShareDriver_OnEvent()
+
+
+
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 08be2ac3..405c4021 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -102,6 +102,10 @@ DeathFrame.xml
# The two duel dialogs + their event driver (decision 0633): registry entries on the same
# StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
DuelFrame.xml
+# The escort-quest confirm + its event driver (decision 1733): one more registry entry on the same
+# StaticPopup engine. Not QuestFrame.xml's or QuestLogFrame.xml's — it belongs to no window, the
+# way the duel dialogs above belong to none.
+QuestShareFrame.xml
# The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
# registry again. Not CraftFrame's — the gate that raises them is the generic item-target
# bind, reached by a poison or a sharpening stone with no profession window open.
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 2ab45bff..b9da3c3f 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -145,6 +145,7 @@ mod ui_pet_stats;
mod ui_petition;
mod ui_quest;
mod ui_quest_log;
+mod ui_quest_share;
mod ui_reputation;
mod ui_saved;
mod ui_script;
@@ -222,6 +223,7 @@ use ui_pet_stats::UiPetStatsPlugin;
use ui_petition::UiPetitionPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
+use ui_quest_share::QuestSharePlugin;
use ui_saved::UiSavedPlugin;
use ui_script::UiScriptPlugin;
use ui_shapeshift::UiShapeshiftPlugin;
@@ -764,6 +766,10 @@ pub fn run(build: BuildId) -> AppExit {
// PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives
// QuestLogFrame.xml over the Era quest-log API.
.add_plugins(UiQuestLogPlugin)
+ // The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the
+ // escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a
+ // lodger in either quest plugin above.
+ .add_plugins(QuestSharePlugin)
.add_plugins(UiChatPlugin)
// The layout cache: the geometry of every window the player has dragged or resized, restored
// at world entry and written back a quiet second after the last drag
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index 3ca0fb22..d8555461 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -1464,12 +1464,38 @@ pub(crate) enum ClientCommand {
QuestgiverStatusQuery {
npc: u64,
},
+ /// Greet a questgiver (`CMSG_QUESTGIVER_HELLO`) — the quest session's END on a non-gossip
+ /// NPC, which is what re-opens its quest list after a decline (decision 1738). The gossip
+ /// twin of this send is [`Self::GossipHello`].
+ QuestgiverHello {
+ npc: u64,
+ },
/// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`): the log window's confirmed abandon
/// (`AbandonQuest()`'s two-step). No ack SMSG — the server clears the `PLAYER_QUEST_LOG` slot
/// fields directly, which the next feed pass reads as the slot going empty.
QuestlogRemove {
slot: u8,
},
+ // ── The party quest-share (decision 1733; bodies in benilla-protocol `messages/quest/share.rs`). ──
+ /// Share the selected quest with the party (`CMSG_PUSHQUESTTOPARTY`) — the quest log's *Share
+ /// Quest* button. The server walks the group itself; the answers come back as one
+ /// `MSG_QUEST_PUSH_RESULT` per member (usually two: the opener, then the outcome).
+ PushQuestToParty {
+ quest: u32,
+ },
+ /// Answer the escort confirm with Yes (`CMSG_QUEST_CONFIRM_ACCEPT`) — a
+ /// `QUEST_FLAGS_PARTY_ACCEPT` quest a party member started. There is no No command: the
+ /// reference sends nothing at all when the popup is dismissed.
+ QuestConfirmAccept {
+ quest: u32,
+ },
+ /// Report our verdict on a quest a party member shared with us (`MSG_QUEST_PUSH_RESULT`) —
+ /// today only the decline; every other verdict on the share is the server's own. `sharer` is
+ /// the giver guid the shared `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under.
+ QuestPushResult {
+ sharer: u64,
+ msg: benilla_protocol::messages::QuestShareMsg,
+ },
// ── The mail arc (decision 0544; writer bodies in benilla-protocol `world/writer/mail.rs`). ──
/// Ask the mailbox's inbox page (`CMSG_GET_MAIL_LIST`) — the window's open verb and its
/// refresh. Answered by `SMSG_MAIL_LIST_RESULT` (a `MailList` event).
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 4a192f2b..d49f453f 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -45,9 +45,10 @@ use loot::{
loot_response, loot_roll, loot_roll_won, loot_start_roll,
};
use quests::{
- quest_complete, quest_detail, quest_failed, quest_giver_failed, quest_giver_invalid,
- quest_giver_status, quest_greeting, quest_log_full, quest_objective_item, quest_objective_kill,
- quest_objectives_complete, quest_offer, quest_progress, quest_template,
+ quest_complete, quest_confirm_accept, quest_detail, quest_failed, quest_giver_failed,
+ quest_giver_invalid, quest_giver_status, quest_greeting, quest_log_full, quest_objective_item,
+ quest_objective_kill, quest_objectives_complete, quest_offer, quest_progress,
+ quest_push_result, quest_template,
};
use spells::{
action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update,
@@ -166,7 +167,14 @@ pub(super) fn apply_net_updates(
),
ResMut,
ResMut,
- ResMut,
+ // Nested pair (the tuple is at the 16-param ceiling): the quest-log template cache, and
+ // the party quest-share state (decision 1733) — the verdicts on a quest we pushed and the
+ // escort confirm. Same family, one slot; unlike most nestings here these two genuinely
+ // belong together, since a share is a quest-log verb.
+ (
+ ResMut,
+ ResMut,
+ ),
ResMut,
ResMut,
ResMut,
@@ -404,7 +412,7 @@ pub(super) fn apply_net_updates(
(mut loot, mut loot_latch, mut loot_rolls),
mut chat_log,
mut quest,
- mut quest_log,
+ (mut quest_log, mut quest_share),
mut go_templates,
mut home_bind,
mut proficiencies,
@@ -549,6 +557,7 @@ pub(super) fn apply_net_updates(
&mut chat_log,
&mut quest,
&mut quest_log,
+ &mut quest_share,
&mut death_net,
&mut group,
&mut taxi,
@@ -1658,7 +1667,7 @@ pub(super) fn apply_net_updates(
quest_giver_status(npc, status, &mut quest)
}
SessionEvent::QuestGreeting(list) => quest_greeting(list, &mut quest),
- SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest),
+ SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest, &net_commands),
SessionEvent::QuestProgress(p) => quest_progress(p, &mut quest),
SessionEvent::QuestOffer(o) => quest_offer(o, &mut quest),
SessionEvent::QuestComplete(c) => quest_complete(c, &mut quest),
@@ -1690,6 +1699,13 @@ pub(super) fn apply_net_updates(
&mut quest,
),
SessionEvent::QuestLogFull => quest_log_full(&mut quest),
+ // The party quest-share (decision 1733): one member's verdict on a quest we pushed,
+ // and the escort-quest confirm. Both park in `QuestShare` for `crate::ui_quest_share`
+ // to name and raise — the guid needs a name query the apply pass has no VM to await.
+ SessionEvent::QuestPushResult { member, msg } => {
+ quest_push_result(member, msg, &mut quest_share)
+ }
+ SessionEvent::QuestConfirmAccept(c) => quest_confirm_accept(c, &mut quest_share),
SessionEvent::QuestGiverInvalid { reason } => quest_giver_invalid(reason, &mut quest),
SessionEvent::QuestGiverFailed { quest_id, reason } => {
quest_giver_failed(quest_id, reason, &mut quest, &mut quest_log, &net_commands)
diff --git a/crates/benilla-app/src/net/apply/quests.rs b/crates/benilla-app/src/net/apply/quests.rs
index 502d311d..9783c922 100644
--- a/crates/benilla-app/src/net/apply/quests.rs
+++ b/crates/benilla-app/src/net/apply/quests.rs
@@ -4,7 +4,8 @@
//! dispatcher, one call per arm.
use benilla_protocol::messages::{
- QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate,
+ QuestComplete, QuestConfirmAccept, QuestDetails, QuestGiverList, QuestOfferReward,
+ QuestRequestItems, QuestShareMsg, QuestTemplate,
};
use bevy::prelude::*;
@@ -12,8 +13,9 @@ use crate::ui_action::{MsgSurface, UiError};
use crate::ui_chat::ChatLog;
use crate::ui_quest::QuestGiver;
use crate::ui_quest_log::QuestLog;
+use crate::ui_quest_share::QuestShare;
-use super::super::NetCommands;
+use super::super::{ClientCommand, NetCommands};
/// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's
/// [`crate::messages::dialog_status`] value. Stored per guid now; the world marker is a later
@@ -33,8 +35,26 @@ pub(super) fn quest_greeting(list: QuestGiverList, quest: &mut QuestGiver) {
}
/// The accept panel: full quest text + rewards on offer (`SMSG_QUESTGIVER_QUEST_DETAILS`).
-pub(super) fn quest_detail(d: QuestDetails, quest: &mut QuestGiver) {
+pub(super) fn quest_detail(d: QuestDetails, quest: &mut QuestGiver, commands: &NetCommands) {
debug!("net: quest detail — quest {} on {:#x}", d.quest_id, d.npc);
+ // **A share that arrives on top of an open window is refused, by the client** (`0x5dbf85`,
+ // decision 1738): `MSG_QUEST_PUSH_RESULT{sharer, BUSY}` and the panel we are reading stays.
+ // `BUSY` is the second and last verdict the client originates. The server has a busy test of
+ // its own (`Player::GetQuestShareInfo`), but it only knows about shares — it cannot see that we
+ // are mid-turn-in at an NPC, which is exactly the case this covers.
+ if benilla_protocol::guid::is_player(d.npc) && quest.is_open() {
+ debug!(
+ "net: busy — refusing {:#x}'s shared quest {}",
+ d.npc, d.quest_id
+ );
+ let _ = commands.0.send(ClientCommand::QuestPushResult {
+ sharer: d.npc,
+ msg: QuestShareMsg::BUSY,
+ });
+ return;
+ }
+ // The trailing flag is a latch, not a field of the view (see `QuestGiver::detail_flag`).
+ quest.detail_flag = d.auto_finish;
quest.open(d.npc, crate::ui_quest::QuestView::Detail(d));
}
@@ -205,6 +225,30 @@ pub(super) fn quest_giver_failed(
quest.clear();
}
+/// One party member's verdict on a quest we shared (`MSG_QUEST_PUSH_RESULT`, decision 1733).
+///
+/// Parked rather than shown: the line's `%s` is the member's NAME, which may still need a
+/// `CMSG_NAME_QUERY` round trip, and this pass has no VM to resolve GlobalStrings through either.
+/// [`crate::ui_quest_share`] owns both.
+pub(super) fn quest_push_result(member: u64, msg: QuestShareMsg, share: &mut QuestShare) {
+ debug!(
+ "net: quest push result — member {member:#x} verdict {}",
+ msg.0
+ );
+ share.push_verdict(member, msg);
+}
+
+/// A party member started a `QUEST_FLAGS_PARTY_ACCEPT` (escort) quest and we are being asked
+/// whether to start it too (`SMSG_QUEST_CONFIRM_ACCEPT`). Parked for the same reason: the popup
+/// names the member, and the name may not be cached yet.
+pub(super) fn quest_confirm_accept(c: QuestConfirmAccept, share: &mut QuestShare) {
+ debug!(
+ "net: quest confirm accept — quest {} ({:?}) from {:#x}",
+ c.quest_id, c.title, c.sender
+ );
+ share.set_confirm(c);
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -299,4 +343,82 @@ mod tests {
assert_eq!(msgs[0].1.key, "ERR_QUEST_FAILED_MAX_COUNT_S");
assert_eq!(msgs[0].1.fill_s.as_deref(), Some(""));
}
+
+ // ── The share's BUSY refusal (decision 1738) ─────────────────────────────────────────────────
+
+ fn detail(npc: u64, quest_id: u32) -> QuestDetails {
+ QuestDetails {
+ npc,
+ quest_id,
+ title: "A Threat Within".into(),
+ details: String::new(),
+ objectives: String::new(),
+ auto_finish: 0,
+ choices: Vec::new(),
+ rewards: Vec::new(),
+ money: 0,
+ reward_spell: 0,
+ }
+ }
+
+ /// **A share arriving on top of an open window is refused by the CLIENT**, with `BUSY` — the
+ /// second and last verdict the client originates (`0x5dbf85`). The window we are already
+ /// reading is not replaced, which is the point: the server's own busy test only knows about
+ /// other shares, so a player mid-turn-in at an NPC is invisible to it.
+ #[test]
+ fn a_share_on_top_of_an_open_window_is_refused_as_busy() {
+ const SHARER: u64 = 0x0000_0000_0000_002A;
+ const NPC: u64 = 0xF130_0000_0000_0007;
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let commands = NetCommands(tx);
+ let mut quest = QuestGiver::default();
+
+ // An NPC's panel is up…
+ quest_detail(detail(NPC, 100), &mut quest, &commands);
+ assert_eq!(quest.npc, Some(NPC));
+ assert!(rx.try_iter().next().is_none(), "opening sends nothing");
+
+ // …and a party member's share lands on top of it.
+ quest_detail(detail(SHARER, 200), &mut quest, &commands);
+ assert_eq!(quest.npc, Some(NPC), "the open window survives the refusal");
+ let sent: Vec<_> = rx.try_iter().collect();
+ assert!(
+ matches!(
+ sent.as_slice(),
+ [ClientCommand::QuestPushResult {
+ sharer: SHARER,
+ msg: QuestShareMsg::BUSY,
+ }]
+ ),
+ "the sharer is told we are busy: {sent:?}"
+ );
+ }
+
+ /// With nothing open, the same share opens the panel normally and answers nothing — the
+ /// control the refusal above would pass without.
+ #[test]
+ fn a_share_with_no_window_open_just_opens() {
+ const SHARER: u64 = 0x0000_0000_0000_002A;
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let commands = NetCommands(tx);
+ let mut quest = QuestGiver::default();
+
+ quest_detail(detail(SHARER, 200), &mut quest, &commands);
+ assert_eq!(quest.npc, Some(SHARER));
+ assert!(rx.try_iter().next().is_none(), "no verdict, no refusal");
+ }
+
+ /// The DETAILS trailing flag is LATCHED on the packet, not read off the open view — the
+ /// reference's `0xbe0824`, whose one reader runs whichever panel is up.
+ #[test]
+ fn the_detail_flag_latches_from_the_packet() {
+ let (tx, _rx) = crossbeam_channel::unbounded();
+ let commands = NetCommands(tx);
+ let mut quest = QuestGiver::default();
+
+ let mut d = detail(0xF130_0000_0000_0007, 100);
+ d.auto_finish = 3;
+ quest_detail(d, &mut quest, &commands);
+ assert_eq!(quest.detail_flag, 3);
+ }
}
diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs
index 9722c879..bab925f8 100644
--- a/crates/benilla-app/src/net/apply/session.rs
+++ b/crates/benilla-app/src/net/apply/session.rs
@@ -160,6 +160,7 @@ pub(super) fn disconnected(
chat_log: &mut ChatLog,
quest: &mut QuestGiver,
quest_log: &mut QuestLog,
+ quest_share: &mut crate::ui_quest_share::QuestShare,
death_net: &mut crate::death::DeathNet,
group: &mut crate::ui_party::GroupState,
taxi: &mut TaxiState,
@@ -226,6 +227,9 @@ pub(super) fn disconnected(
chat_log.clear_session();
quest.clear_session();
quest_log.clear_session();
+ // A verdict on a share nobody is listening for any more, and a confirm whose server-side
+ // latch died with the socket (decision 1733).
+ quest_share.clear_session();
group.clear_session();
taxi.clear_session();
mail.clear_session();
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index df730dd5..04fdf41f 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -917,7 +917,13 @@ fn writer_loop(
}
ClientCommand::QuestQuery { quest } => w.quest_query(quest),
ClientCommand::QuestgiverStatusQuery { npc } => w.questgiver_status_query(npc),
+ ClientCommand::QuestgiverHello { npc } => w.questgiver_hello(npc),
ClientCommand::QuestlogRemove { slot } => w.questlog_remove_quest(slot),
+ ClientCommand::PushQuestToParty { quest } => w.push_quest_to_party(quest),
+ ClientCommand::QuestConfirmAccept { quest } => w.quest_confirm_accept(quest),
+ ClientCommand::QuestPushResult { sharer, msg } => {
+ w.quest_push_result(sharer, msg)
+ }
ClientCommand::GetMailList { mailbox } => w.get_mail_list(mailbox),
ClientCommand::SendMail {
mailbox,
diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs
index 148bf299..322a060e 100644
--- a/crates/benilla-app/src/ui_action/errors.rs
+++ b/crates/benilla-app/src/ui_action/errors.rs
@@ -21,10 +21,12 @@
//! absent key shows nothing (the reference's data-suppression face) and localization rides
//! for free.
-use crate::ui_items::{count_of, InventoryScope};
+use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use crate::net::ObjectStore;
+use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
+use crate::ui_items::{count_of, InventoryScope};
/// Cast failures queued for the UI error line — the wire triple from `SMSG_CAST_RESULT` and the
/// local refusals alike. The spell id rides along because the display layer keys several messages
@@ -179,6 +181,34 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option) -
(!text.is_empty()).then_some(text)
}
+/// Show already-resolved [`UiError`] lines on the surface each message record names — the second
+/// half of every [`MsgSurface`] tenant's drain, factored out once it had three (the questgiver
+/// refusals 0669, the auction house 1523, the party quest-share 1733). The tenants differ only in
+/// how they *build* their lines (a window's queue, a deferred item template, a guid awaiting a
+/// name); what they do with a resolved one is this, identically, down to the debug line — which
+/// exists because the chat path keeps no log of its own, so without it a live probe can count
+/// lines but never read one (0669's in-app leg).
+///
+/// `who` is the caller's module tag for that debug line.
+pub(crate) fn show_messages(
+ script: &mut UiScript,
+ chat: &mut ChatLog,
+ who: &str,
+ lines: impl IntoIterator,
+) {
+ for (surface, text) in lines {
+ debug!("{who}: message ({surface:?}) {text:?}");
+ match surface {
+ MsgSurface::Chat => {
+ chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
+ }
+ MsgSurface::Error => {
+ script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
+ }
+ }
+ }
+}
+
/// `UNIT_FIELD_FLAGS` bits the attack-start validator refuses on, paired with the message each
/// raises — read at `0x612eec`+ in the binary's own test order (wow-re
/// `object-layer/scratch/pet-command-validators.md` §2). All four are crowd control: the actor is
diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs
index a45d3fa7..01827de1 100644
--- a/crates/benilla-app/src/ui_action/mod.rs
+++ b/crates/benilla-app/src/ui_action/mod.rs
@@ -65,8 +65,8 @@ pub(crate) struct CooldownEvents;
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use errors::{
- attack_actor_refusal, reagent_totem_refusal, ui_error_text, CastErrors, CastFail, MountErrors,
- MsgSurface, UiError, UiErrorKeys, UiErrorTexts,
+ attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors,
+ CastFail, MountErrors, MsgSurface, 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_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs
index b2f69d90..080f0c0c 100644
--- a/crates/benilla-app/src/ui_auction/mod.rs
+++ b/crates/benilla-app/src/ui_auction/mod.rs
@@ -40,16 +40,15 @@ use bevy::prelude::*;
use benilla_protocol::messages::{auction_filter, AuctionListEntry};
use benilla_ui::script::{
- AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory,
- ScriptValue, UiScript, BIDDER, LIST, OWNER,
+ AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory, UiScript,
+ BIDDER, LIST, OWNER,
};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
-use crate::ui_action::{ui_error_text, MsgSurface, UiError};
-use crate::ui_chat::{ChatEvent, ChatEventKind};
+use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
@@ -630,19 +629,7 @@ fn feed_auction(
}
}
auction.messages = deferred;
- for (surface, text) in lines {
- // Greppable, because the chat path has no log of its own — without this a live probe
- // can count lines but never read one (0669's in-app leg).
- debug!("ui_auction: message ({surface:?}) {text:?}");
- match surface {
- MsgSurface::Chat => {
- chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
- }
- MsgSurface::Error => {
- script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
- }
- }
- }
+ show_messages(&mut script, &mut chat, "ui_auction", lines);
}
let self_guid = self_q.iter().next().map(|g| g.0);
diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs
index 2cd497b6..bea4a97a 100644
--- a/crates/benilla-app/src/ui_quest.rs
+++ b/crates/benilla-app/src/ui_quest.rs
@@ -17,6 +17,7 @@ use std::collections::HashMap;
use benilla_protocol::messages::{
QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestRewardItem,
+ QuestShareMsg,
};
use bevy::prelude::*;
@@ -27,9 +28,9 @@ use benilla_ui::script::{
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
-use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
-use crate::ui_action::{ui_error_text, MsgSurface, UiError};
-use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
+use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
+use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError};
+use crate::ui_chat::ChatLog;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -55,6 +56,12 @@ pub(crate) struct QuestGiver {
pub(crate) npc: Option,
/// The open panel's wire view.
pub(crate) view: Option,
+ /// The trailing `u32` of the last `SMSG_QUESTGIVER_QUEST_DETAILS`
+ /// ([`benilla_protocol::messages::QuestDetails::auto_finish`]) — the reference's `0xbe0824`,
+ /// a **latch** written by the DETAILS handler rather than a field of the open view, because
+ /// that is the shape the binary has and because its one reader ([`end_quest_session`]) runs
+ /// whichever panel is up. Non-zero suppresses the giver re-open on close (decision 1738).
+ pub(crate) detail_flag: u32,
/// Per-guid dialog status (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's value, stored for a
/// later world-marker slice.
statuses: HashMap,
@@ -244,6 +251,18 @@ impl NpcSession for QuestGiver {
self.npc.filter(|g| !benilla_protocol::guid::is_item(*g))
}
+ /// **A party share is not range-guarded** (decision 1733). Its giver is a live player with a
+ /// position, a name and a portrait, so it stays a full session for [`crate::ui_session`]'s
+ /// *other* consumer — but the guard's threshold is the NPC **service** range
+ /// ([`crate::target::SERVICE_RANGE_SQ`], 5.56 yd), a rule about reaching an NPC's counter, and
+ /// a share has no counter to reach. The server's own bound is `QUEST_SHARE_DISTANCE`, 14.0 yd
+ /// (vmangos `Object.h:72`), tested at ACCEPT and answered with `QUEST_PARTY_MSG_TOO_FAR` —
+ /// so a sharer standing six yards off would have had the panel yanked out from under a quest
+ /// the server was perfectly willing to grant.
+ fn range_guarded(&self) -> bool {
+ !self.npc.is_some_and(benilla_protocol::guid::is_player)
+ }
+
fn close(&mut self) {
self.clear();
}
@@ -449,19 +468,7 @@ fn feed_quest(
ui_error_text(&msg, &get).map(|text| (surface, text))
})
.collect();
- for (surface, text) in lines {
- // The resolved line, greppable: the chat path has no log of its own, so without this a
- // live probe can count lines but never read one (decision 0669's in-app leg).
- debug!("ui_quest: message ({surface:?}) {text:?}");
- match surface {
- MsgSurface::Chat => {
- chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
- }
- MsgSurface::Error => {
- script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
- }
- }
- }
+ show_messages(&mut script, &mut chat, "ui_quest", lines);
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let fresh = snapshot(
&giver,
@@ -518,10 +525,63 @@ fn feed_quest(
/// Drain the Lua intents: the greeting-row selects (map to the row's quest id → QUERY_QUEST for an
/// available quest, COMPLETE_QUEST for an active one) and the button actions (Accept/Continue/Reward
/// → the matching CMSG; Close → a local clear, no packet).
+/// **The quest session's one end** — the reference's `0x501130`, which every way out of the
+/// questgiver window funnels through: `DeclineQuest()`, `CloseQuest()` (ESC, the window's own
+/// OnHide), the walk-away watchdog and the leave-world teardown are four of its eleven callers, and
+/// they all do the same thing. That is why benilla models one [`QuestAction::Close`] and not the
+/// `Decline`/`Close` pair 1733 briefly split it into: two Lua verbs, one routine (decision 1738,
+/// VERIFIED by the wow-re §5 dispatched for 1733).
+///
+/// It sends, and what it sends depends on **the giver's object type**, not on which button was
+/// pressed:
+///
+/// - **A player** — a party member whose shared quest we are turning down. The answer they are
+/// waiting on: `MSG_QUEST_PUSH_RESULT{sharerGuid, DECLINE_QUEST}`. This is the only verdict the
+/// client ever originates besides `BUSY`.
+/// - **A unit** — an ordinary questgiver, and the reference **re-opens its list**:
+/// `CMSG_GOSSIP_HELLO` for a gossip-flagged NPC, `CMSG_QUESTGIVER_HELLO` otherwise. Declining a
+/// quest putting you back in the NPC's menu is not a courtesy the server does; it is this send.
+/// benilla asserted the opposite in `QuestFrame.xml` ("vanilla's client-side decline sends no
+/// packet") from 0088 until 1738 refuted it at the bytes.
+///
+/// `detail_flag` suppresses the unit re-open when non-zero — the trailing `u32` of
+/// `SMSG_QUESTGIVER_QUEST_DETAILS` ([`QuestDetails::auto_finish`]), whose only reader in the whole
+/// image is this routine. `npc_flags` is `None` when the giver is not a streamed unit, which is
+/// also how an item giver (0664) and a player fall out of the unit branch.
+fn end_quest_session(npc: u64, detail_flag: u32, npc_flags: Option, commands: &NetCommands) {
+ if benilla_protocol::guid::is_player(npc) {
+ debug!("ui_quest: declining {npc:#x}'s shared quest");
+ let _ = commands.0.send(ClientCommand::QuestPushResult {
+ sharer: npc,
+ msg: QuestShareMsg::DECLINE_QUEST,
+ });
+ return;
+ }
+ let Some(flags) = npc_flags else {
+ // Not a streamed unit: an item giver, or an NPC that left view under the open window.
+ // Nothing to re-open and nobody to answer.
+ debug!("ui_quest: close on non-unit giver {npc:#x} (no packet)");
+ return;
+ };
+ if detail_flag != 0 {
+ debug!("ui_quest: close on {npc:#x} — detail flag {detail_flag} suppresses the re-open");
+ return;
+ }
+ if flags & crate::target::cursor_mode::npc_flags::GOSSIP != 0 {
+ debug!("ui_quest: close on {npc:#x} — re-opening the gossip menu");
+ let _ = commands.0.send(ClientCommand::GossipHello { guid: npc });
+ } else {
+ debug!("ui_quest: close on {npc:#x} — re-opening the quest list");
+ let _ = commands.0.send(ClientCommand::QuestgiverHello { npc });
+ }
+}
+
fn drain_quest(
script: Option>,
mut giver: ResMut,
commands: Res,
+ index: Res,
+ stores: Query<&ObjectStore>,
) {
let Some(mut script) = script else {
return;
@@ -532,6 +592,13 @@ fn drain_quest(
script.take_quest_actions();
return;
};
+ // The giver's live `UNIT_NPC_FLAGS`, for [`end_quest_session`]'s gossip/quest-list fork.
+ // `None` for anything that is not a streamed unit — an item giver, a player, a despawn.
+ let npc_flags = index
+ .0
+ .get(&npc)
+ .and_then(|e| stores.get(*e).ok())
+ .map(|s| s.0.unit_npc_flags());
// Greeting-row selects: resolve the 1-based row to its quest id off the open greeting view.
for sel in script.take_quest_selects() {
@@ -574,7 +641,7 @@ fn drain_quest(
for action in script.take_quest_actions() {
match action {
QuestAction::Close => {
- debug!("ui_quest: client-side close (no packet)");
+ end_quest_session(npc, giver.detail_flag, npc_flags, &commands);
giver.clear();
}
QuestAction::Accept => {
@@ -919,4 +986,122 @@ mod tests {
Some("Your quest log is full.")
);
}
+
+ // ── The party share's one client-originated verdict (decision 1733) ──────────────────────────
+
+ /// Run `lua` against a quest window open on `giver`, and return what the drain sent.
+ /// `npc_flags` seats the giver as a streamed unit with those `UNIT_NPC_FLAGS`; `None` leaves it
+ /// unstreamed (an item giver, or an NPC that left view).
+ fn drain_after(
+ giver: u64,
+ npc_flags: Option,
+ detail_flag: u32,
+ lua: &str,
+ ) -> Vec {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let mut app = App::new();
+ app.insert_resource(NetCommands(tx));
+ app.init_resource::();
+ if let Some(flags) = npc_flags {
+ // 147 = `UNIT_NPC_FLAGS` (the descriptor index `unit_npc_flags()` reads).
+ let fields = benilla_protocol::ObjectFields::from_pairs(&[(147, flags)]);
+ let e = app.world_mut().spawn(ObjectStore(fields)).id();
+ app.world_mut()
+ .resource_mut::()
+ .0
+ .insert(giver, e);
+ }
+ let mut quest = QuestGiver {
+ detail_flag,
+ ..Default::default()
+ };
+ quest.open(
+ giver,
+ QuestView::Detail(QuestDetails {
+ npc: giver,
+ quest_id: 1,
+ title: "A Threat Within".into(),
+ details: String::new(),
+ objectives: String::new(),
+ auto_finish: detail_flag,
+ choices: Vec::new(),
+ rewards: Vec::new(),
+ money: 0,
+ reward_spell: 0,
+ }),
+ );
+ app.insert_resource(quest);
+ let script = UiScript::new().unwrap();
+ script.run(lua).unwrap();
+ app.insert_non_send_resource(script);
+ app.add_systems(Update, drain_quest);
+ app.update();
+ rx.try_iter().collect()
+ }
+
+ const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
+ const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
+
+ /// **Ending the session on a SHARED quest answers the sharer — and `CloseQuest` does it too.**
+ /// 1733 shipped these as two actions on the assumption that only `DeclineQuest` answered; the
+ /// §5 found both Lua verbs calling one routine (`0x501130`), so ESC-ing a share panel reports
+ /// the decline exactly as the button does. This test is the corrected form of 1733's
+ /// `closing_a_shared_quest_panel_is_not_a_decline`, which asserted the opposite.
+ #[test]
+ fn every_way_out_of_a_shared_quest_answers_the_sharer() {
+ for verb in ["DeclineQuest()", "CloseQuest()"] {
+ let sent = drain_after(SHARER, None, 0, verb);
+ assert!(
+ matches!(
+ sent.as_slice(),
+ [ClientCommand::QuestPushResult {
+ sharer: SHARER,
+ msg: QuestShareMsg::DECLINE_QUEST,
+ }]
+ ),
+ "{verb} must answer the sharer: {sent:?}"
+ );
+ }
+ }
+
+ /// **Ending it on an NPC RE-OPENS the giver**, which benilla asserted for years that it did not
+ /// ("vanilla's client-side decline sends no packet"). The fork is on the NPC's own gossip flag:
+ /// `CMSG_GOSSIP_HELLO` for a gossip-flagged NPC, `CMSG_QUESTGIVER_HELLO` otherwise. This is the
+ /// mechanism behind the reference putting you back in the questgiver's menu after a decline.
+ #[test]
+ fn ending_the_session_on_an_npc_reopens_its_list() {
+ use crate::target::cursor_mode::npc_flags;
+
+ let sent = drain_after(NPC, Some(0), 0, "DeclineQuest()");
+ assert!(
+ matches!(
+ sent.as_slice(),
+ [ClientCommand::QuestgiverHello { npc: NPC }]
+ ),
+ "a plain questgiver gets QUESTGIVER_HELLO: {sent:?}"
+ );
+
+ let sent = drain_after(NPC, Some(npc_flags::GOSSIP), 0, "DeclineQuest()");
+ assert!(
+ matches!(sent.as_slice(), [ClientCommand::GossipHello { guid: NPC }]),
+ "a gossip-flagged NPC gets GOSSIP_HELLO: {sent:?}"
+ );
+ }
+
+ /// The DETAILS packet's trailing `u32` **suppresses** that re-open when non-zero — the field
+ /// benilla parsed and ignored until the §5 found its one reader. Ignoring it meant a
+ /// suppressed giver was re-opened anyway.
+ #[test]
+ fn a_non_zero_detail_flag_suppresses_the_reopen() {
+ let sent = drain_after(NPC, Some(0), 1, "DeclineQuest()");
+ assert!(sent.is_empty(), "flag 1 suppresses the re-open: {sent:?}");
+ }
+
+ /// A giver that is not a streamed unit sends nothing at all: an item giver (0664) has no list
+ /// to re-open, and neither does an NPC that walked out of view under the open window.
+ #[test]
+ fn an_unstreamed_giver_ends_the_session_silently() {
+ let sent = drain_after(NPC, None, 0, "CloseQuest()");
+ assert!(sent.is_empty(), "no unit, no re-open: {sent:?}");
+ }
}
diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs
index 07eb0a63..97627052 100644
--- a/crates/benilla-app/src/ui_quest_log.rs
+++ b/crates/benilla-app/src/ui_quest_log.rs
@@ -40,7 +40,8 @@ use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use benilla_protocol::messages::{
- quest_slot_state, QuestLogSlot, QuestObjective, QuestTemplate, PLAYER_QUEST_LOG_SLOTS,
+ quest_flags, quest_slot_state, QuestLogSlot, QuestObjective, QuestTemplate,
+ PLAYER_QUEST_LOG_SLOTS,
};
use benilla_protocol::ObjectFields;
use benilla_ui::script::{
@@ -147,6 +148,7 @@ impl Plugin for UiQuestLogPlugin {
// frame's clock (the `minimap::feed_game_time` shape).
feed_server_clock.before(UiInput),
drain_quest_log_abandons.after(UiInput),
+ drain_quest_log_pushes.after(UiInput),
drain_quest_log_collapses.after(UiInput),
),
);
@@ -503,7 +505,8 @@ fn feed_quest_log(
is_header: true,
collapsed,
complete: 0,
- timer: 0, // a header row is not a quest and never carries a timer
+ timer: 0, // a header row is not a quest and never carries a timer
+ pushable: false, // nor is it shareable — there is no quest under the row
objectives: Vec::new(),
});
entry_slots.push(None);
@@ -513,15 +516,26 @@ fn feed_quest_log(
if collapsed {
continue; // folded: the quest stays in the log, just not in the visible list
}
- let (title, level, objectives) = match quest_log.template(r.quest_id, &commands) {
+ let (title, level, pushable, objectives) = match quest_log
+ .template(r.quest_id, &commands)
+ {
Some(t) => (
t.title.clone(),
t.level,
+ // `QUEST_FLAGS_SHARABLE` (vmangos `QuestDef.h:153`) — the whole of the
+ // shareable test, and it is the CLIENT's alone: vmangos's
+ // `HandlePushQuestToParty` never checks the bit, it only re-tests it on the
+ // receiver's accept (`Player::CanShareQuest`). So an unshareable quest whose
+ // button we wrongly enabled would push, and the party would get a detail panel
+ // for a quest the server then refuses (decision 1733).
+ t.flags & quest_flags::SHARABLE != 0,
build_objectives(t, &r.log_slot, &store.0, &mut items, &mut names, &commands),
),
// Title/level/objectives placeholder while the template is in flight — the
- // QUEST_LOG_UPDATE refresh on landing fills the row in.
- None => ("...".to_string(), 0, Vec::new()),
+ // QUEST_LOG_UPDATE refresh on landing fills the row in. `pushable` false with it:
+ // the reference reads the same cache, so its button is dark until the answer lands
+ // too.
+ None => ("...".to_string(), 0, false, Vec::new()),
};
let complete = if r.log_slot.state & quest_slot_state::COMPLETE != 0 {
1
@@ -538,6 +552,7 @@ fn feed_quest_log(
is_header: false,
collapsed: false,
complete,
+ pushable,
// The slot's raw deadline (absolute unix seconds; 0 = untimed) — carried, never
// converted here. The countdown is subtracted per Lua call against the server
// clock, so this snapshot stays stable while the number on screen ticks
@@ -781,6 +796,19 @@ fn drain_quest_log_collapses(
}
}
+/// `QuestLogPushQuest()` — the *Share Quest* button (decision 1733). The engine already resolved
+/// the selection to a quest id at click time, so this is a straight relay; the server addresses the
+/// party itself.
+fn drain_quest_log_pushes(script: Option>, commands: Res) {
+ let Some(mut script) = script else {
+ return;
+ };
+ for quest in script.take_quest_log_pushes() {
+ debug!("ui_quest_log: sharing quest {quest} with the party");
+ let _ = commands.0.send(ClientCommand::PushQuestToParty { quest });
+ }
+}
+
fn drain_quest_log_abandons(
script: Option>,
quest_log: Res,
@@ -816,6 +844,7 @@ mod tests {
collapsed: false,
complete: 0,
timer: 0,
+ pushable: false,
objectives: Vec::new(),
}
}
@@ -830,6 +859,7 @@ mod tests {
collapsed: false,
complete: 0,
timer: 0,
+ pushable: false,
objectives: Vec::new(),
}
}
diff --git a/crates/benilla-app/src/ui_quest_share.rs b/crates/benilla-app/src/ui_quest_share.rs
new file mode 100644
index 00000000..85e1a280
--- /dev/null
+++ b/crates/benilla-app/src/ui_quest_share.rs
@@ -0,0 +1,316 @@
+//! **Party quest sharing** — the *Share Quest* button's two answer paths (decision 1733).
+//!
+//! Everything about a share that is not already the questgiver panel's job lives here, and that
+//! turns out to be exactly the two things the sharer's own client cannot see: what each party
+//! member *did* with the quest, and — for an escort quest — the confirm box a member gets when
+//! somebody else starts it.
+//!
+//! **What is NOT here, deliberately.** The receiving half of an ordinary share needs no code of its
+//! own: the server sends the receiver a plain `SMSG_QUESTGIVER_QUEST_DETAILS` whose giver guid is
+//! the *sharer's player guid* (vmangos `QuestHandler.cpp:450`), so the shared quest arrives on the
+//! questgiver panel already built, and Accept answers it with the ordinary
+//! `CMSG_QUESTGIVER_ACCEPT_QUEST` addressed to that player. [`crate::ui_quest`] needs to know a
+//! share from an NPC offer for exactly two things — the decline packet and the range guard — and it
+//! reads both off the giver guid's own type bits ([`benilla_protocol::guid::is_player`]).
+//!
+//! **The two flows, and why one resource holds both.** They are one server-side latch
+//! (`Player::SetQuestShareInfo` — one `{sharer, quest}` pair per player, set by the push and by an
+//! escort accept alike), so a client that modelled them separately would be modelling one thing
+//! twice.
+//!
+//! 1. **The verdicts.** `MSG_QUEST_PUSH_RESULT` comes back to the SHARER, once per member and
+//! usually twice (`SHARING_QUEST` when the push goes out, then the outcome). Each is an
+//! `ERR_QUEST_PUSH_*_S` line naming the member — `%s`-filled from the guid in the packet, which
+//! is the member the verdict is *about*, never the sharer (the direction trap on
+//! [`benilla_protocol::messages::QuestPushResult`]).
+//! 2. **The escort confirm.** `SMSG_QUEST_CONFIRM_ACCEPT` arrives when a party member starts a
+//! `QUEST_FLAGS_PARTY_ACCEPT` quest. It raises `QUEST_ACCEPT_CONFIRM(memberName, questTitle)`,
+//! the reference's own event (`UIParent.lua:353` → `StaticPopup_Show("QUEST_ACCEPT", arg1,
+//! arg2)`); Yes calls `ConfirmAcceptQuest()`, and **No sends nothing at all** — which is why
+//! there is no decline command here.
+//!
+//! **A name we do not have yet holds its line — a NAMED divergence, not fidelity.** Both paths
+//! address a party member by guid and display a *name*, so both go through the [`NameCache`]'s
+//! ask-once resolve. The reference does not: it reads the object-name cache `0xc0e228` through
+//! `0x55f080` with a **null callback**, so a miss returns 0 and the message is **silently dropped**
+//! — no query, no defer, no line (decision 1738's §5). That is a quirk of a synchronous object
+//! manager that always has its party members to hand, not a behaviour worth reproducing: benilla's
+//! cache may genuinely not hold a member yet, and losing "Thrall has declined your quest" because a
+//! name query was in flight is strictly worse than showing it a frame later. So a verdict whose
+//! name has not landed is kept and retried, with [`crate::ui_loot`]'s bounded budget so a guid the
+//! server will never name cannot pin a line forever. [`crate::ui_duel`] takes the same divergence
+//! against the same reference behaviour, and says so in its own header.
+
+use benilla_protocol::messages::{QuestConfirmAccept, QuestShareMsg};
+use benilla_ui::script::{ScriptValue, UiScript};
+use bevy::prelude::*;
+
+use crate::names::NameCache;
+use crate::net::{ClientCommand, NetCommands};
+use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError};
+use crate::ui_chat::ChatLog;
+use crate::ui_script::UiInput;
+
+/// How many frames a queued line waits for its `%s` before it is dropped — [`crate::ui_loot`]'s
+/// `RECEIVE_MAX_TRIES`, and the same reasoning: a name query is one round trip, so any real answer
+/// lands inside a couple of frames, and a guid the server declines to name (it caches the negative)
+/// would otherwise hold its line for the rest of the session.
+const NAME_MAX_TRIES: u16 = 120;
+
+/// One `MSG_QUEST_PUSH_RESULT` verdict waiting for the member's name.
+struct PendingVerdict {
+ member: u64,
+ msg: QuestShareMsg,
+ tries: u16,
+}
+
+/// The share state: the verdicts on quests we pushed, and the escort confirm we owe an answer.
+#[derive(Resource, Default)]
+pub(crate) struct QuestShare {
+ verdicts: Vec,
+ /// The confirm the server sent, held until its sender's name resolves and the event fires.
+ pending_confirm: Option,
+ /// The quest `ConfirmAcceptQuest()` answers — latched when the popup's event fires and kept
+ /// after it, because the Lua verb takes no argument (ref `StaticPopup.lua:731-733`): the
+ /// client answers the confirm it was last asked, and a *second* confirm replaces the first
+ /// exactly as it replaces the server's own one-slot latch.
+ confirm_quest: Option,
+}
+
+impl QuestShare {
+ /// Queue one verdict on a quest we shared (`MSG_QUEST_PUSH_RESULT`).
+ pub(crate) fn push_verdict(&mut self, member: u64, msg: QuestShareMsg) {
+ self.verdicts.push(PendingVerdict {
+ member,
+ msg,
+ tries: 0,
+ });
+ }
+
+ /// Hold an escort confirm (`SMSG_QUEST_CONFIRM_ACCEPT`) for the feed to raise. A second
+ /// confirm replaces a first that has not fired yet — the server keeps one latch, so the older
+ /// question is already dead.
+ pub(crate) fn set_confirm(&mut self, c: QuestConfirmAccept) {
+ self.pending_confirm = Some(c);
+ }
+
+ /// The socket died: no verdict is worth showing after it and no confirm can still be answered
+ /// (the server's latch went with the session).
+ pub(crate) fn clear_session(&mut self) {
+ self.verdicts.clear();
+ self.pending_confirm = None;
+ self.confirm_quest = None;
+ }
+}
+
+/// The `ERR_QUEST_PUSH_*` GlobalStrings key one verdict shows, and the surface it shows on.
+///
+/// The value→key column is vmangos `QuestDef.h:62-70`, whose enum comments name the client's own
+/// error constants one-for-one — the same corroboration decision 0669's refusal table started
+/// from. `None` for an unmapped byte is the reference's data-suppression face, not a gap: an
+/// unknown verdict shows nothing rather than an English guess.
+///
+/// **Both columns are now VERIFIED at the bytes** (decision 1738's fold-back of the §5 dispatched
+/// with 1733). The inbound `0x276` arm at `0x5e4781` maps `msg 0..8` onto message ids `0x181`-`0x189`
+/// — contiguous, in exactly this order — and all nine records carry **`kind 0`** (the nine
+/// `push 0x0` at `0x487dc9`-`0x487e69`), which `CGGameUI::DisplayError` dispatches to the chat
+/// window as `CHAT_MSG_SYSTEM`. Not one of them reaches `UIErrorsFrame`, which is what 1733
+/// guessed and is why the guess is recorded as having been a guess.
+fn verdict_message(msg: QuestShareMsg) -> Option<(MsgSurface, &'static str)> {
+ let key = match msg {
+ QuestShareMsg::SHARING_QUEST => "ERR_QUEST_PUSH_SUCCESS_S",
+ QuestShareMsg::CANT_TAKE_QUEST => "ERR_QUEST_PUSH_INVALID_S",
+ QuestShareMsg::ACCEPT_QUEST => "ERR_QUEST_PUSH_ACCEPTED_S",
+ QuestShareMsg::DECLINE_QUEST => "ERR_QUEST_PUSH_DECLINED_S",
+ QuestShareMsg::TOO_FAR => "ERR_QUEST_PUSH_TOO_FAR_S",
+ QuestShareMsg::BUSY => "ERR_QUEST_PUSH_BUSY_S",
+ QuestShareMsg::LOG_FULL => "ERR_QUEST_PUSH_LOG_FULL_S",
+ QuestShareMsg::HAVE_QUEST => "ERR_QUEST_PUSH_ONQUEST_S",
+ QuestShareMsg::FINISH_QUEST => "ERR_QUEST_PUSH_ALREADY_DONE_S",
+ _ => return None,
+ };
+ Some((MsgSurface::Chat, key))
+}
+
+/// Owns [`QuestShare`] and its two systems — a plugin of its own rather than a lodger in
+/// [`crate::ui_quest`]'s, because nothing here is bound to the questgiver window: the verdicts are
+/// fired from the quest LOG and the confirm has no window at all.
+pub(crate) struct QuestSharePlugin;
+
+impl Plugin for QuestSharePlugin {
+ fn build(&self, app: &mut App) {
+ // The [`crate::ui_duel`] shape exactly: feed before the input pass so a verdict line and
+ // a confirm are on screen the same frame the packet landed, drain after it so the popup's
+ // Yes goes out the same frame it was clicked.
+ app.init_resource::().add_systems(
+ Update,
+ (
+ feed_quest_share.before(UiInput),
+ drain_quest_share.after(UiInput),
+ ),
+ );
+ }
+}
+
+/// Resolve the queued names, then show what they unlocked: the verdict lines, and the escort
+/// confirm's `QUEST_ACCEPT_CONFIRM`.
+fn feed_quest_share(
+ script: Option>,
+ mut share: ResMut,
+ mut names: ResMut,
+ commands: Res,
+ mut chat: ResMut,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+
+ // The verdicts: one line per resolved name, the rest kept for the next frame.
+ let mut lines: Vec<(MsgSurface, String)> = Vec::new();
+ let mut waiting = Vec::new();
+ for mut v in std::mem::take(&mut share.verdicts) {
+ let Some((surface, key)) = verdict_message(v.msg) else {
+ debug!(
+ "ui_quest_share: unmapped push verdict {} — no line",
+ v.msg.0
+ );
+ continue;
+ };
+ match names.resolve(v.member, &commands).map(str::to_string) {
+ Some(name) => {
+ let err = UiError {
+ key,
+ fill_s: Some(name),
+ fill_d: None,
+ info: false,
+ };
+ let get = |k: &str| script.lua().globals().get::(k).ok();
+ if let Some(text) = ui_error_text(&err, &get) {
+ lines.push((surface, text));
+ }
+ }
+ None => {
+ v.tries += 1;
+ if v.tries < NAME_MAX_TRIES {
+ waiting.push(v);
+ } else {
+ debug!(
+ "ui_quest_share: gave up naming {:#x} for verdict {}",
+ v.member, v.msg.0
+ );
+ }
+ }
+ }
+ }
+ share.verdicts = waiting;
+ show_messages(&mut script, &mut chat, "ui_quest_share", lines);
+
+ // The escort confirm — held until the member's name lands, then raised with the reference's own
+ // two args: `QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"`, player first.
+ if let Some(c) = share.pending_confirm.as_ref() {
+ if let Some(name) = names.resolve(c.sender, &commands).map(str::to_string) {
+ let title = c.title.clone();
+ share.confirm_quest = Some(c.quest_id);
+ share.pending_confirm = None;
+ script.fire_event(
+ "QUEST_ACCEPT_CONFIRM",
+ vec![ScriptValue::Str(name), ScriptValue::Str(title)],
+ );
+ }
+ }
+}
+
+/// `ConfirmAcceptQuest()` — the confirm popup's Yes, answered against the latched quest id.
+fn drain_quest_share(
+ script: Option>,
+ share: Res,
+ commands: Res,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+ for _ in 0..script.take_quest_confirms() {
+ let Some(quest) = share.confirm_quest else {
+ debug!("ui_quest_share: ConfirmAcceptQuest with no confirm held — ignored");
+ continue;
+ };
+ let _ = commands.0.send(ClientCommand::QuestConfirmAccept { quest });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_wire_verdict_has_a_key_and_an_unknown_has_none() {
+ for v in 0u8..=8 {
+ assert!(
+ verdict_message(QuestShareMsg(v)).is_some(),
+ "verdict {v} unmapped"
+ );
+ }
+ assert!(verdict_message(QuestShareMsg(9)).is_none());
+ assert!(verdict_message(QuestShareMsg(0xFF)).is_none());
+ }
+
+ /// The nine keys are distinct — a copy-paste in the table would silently show one member's
+ /// outcome as another's.
+ #[test]
+ fn verdict_keys_are_distinct() {
+ let mut keys: Vec<&str> = (0u8..=8)
+ .filter_map(|v| verdict_message(QuestShareMsg(v)).map(|(_, k)| k))
+ .collect();
+ keys.sort_unstable();
+ let n = keys.len();
+ keys.dedup();
+ assert_eq!(keys.len(), n, "duplicate ERR_QUEST_PUSH_* key");
+ }
+
+ /// The RUNTIME leg on the real client data ([`crate::ui_quest`]'s refusal-table test, same
+ /// shape and same reason): every `ERR_QUEST_PUSH_*` key the table can emit resolves to a
+ /// non-empty string in the shipped 1.12 `GlobalStrings.lua`, and every one of them carries the
+ /// `%s` the member's name fills — a key that resolved but had no token would silently show the
+ /// same line for every member. Skips without client data.
+ #[test]
+ fn every_verdict_key_resolves_and_names_a_member() {
+ let data = benilla_formats::wow_data_or_skip!();
+ 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 s = benilla_ui::script::UiScript::new().expect("VM");
+ s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
+ let g = |key: &str| s.lua().globals().get::(key).ok();
+
+ for raw in 0u8..=8 {
+ let (_, key) = verdict_message(QuestShareMsg(raw)).expect("mapped");
+ let text = g(key).unwrap_or_default();
+ assert!(!text.is_empty(), "{key} (verdict {raw}) missing");
+ assert!(text.contains("%s"), "{key} names no member: {text:?}");
+ }
+
+ // The two ends of the flow, filled: the opener the sharer sees the instant they click, and
+ // the answer a decline produces.
+ let line = |raw: u8| {
+ let (_, key) = verdict_message(QuestShareMsg(raw)).unwrap();
+ ui_error_text(
+ &UiError {
+ key,
+ fill_s: Some("Mate".into()),
+ fill_d: None,
+ info: false,
+ },
+ &g,
+ )
+ };
+ assert_eq!(
+ line(QuestShareMsg::SHARING_QUEST.0).as_deref(),
+ Some("Sharing quest with Mate...")
+ );
+ assert_eq!(
+ line(QuestShareMsg::DECLINE_QUEST.0).as_deref(),
+ Some("Mate has declined your quest")
+ );
+ }
+}
diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs
index 82a59c3d..77eb375e 100644
--- a/crates/benilla-app/src/ui_script/mod.rs
+++ b/crates/benilla-app/src/ui_script/mod.rs
@@ -957,6 +957,9 @@ mod petition_tests;
#[cfg(test)]
mod raid_tests;
+#[cfg(test)]
+mod quest_share_tests;
+
#[cfg(test)]
mod quest_tests;
diff --git a/crates/benilla-app/src/ui_script/quest_share_tests.rs b/crates/benilla-app/src/ui_script/quest_share_tests.rs
new file mode 100644
index 00000000..9c26a25d
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/quest_share_tests.rs
@@ -0,0 +1,118 @@
+//! The escort-quest confirm (decision 1733, `QuestShareFrame.xml`): the Lua wiring between the
+//! `QUEST_ACCEPT_CONFIRM` event `ui_quest_share`'s feed fires and the shared StaticPopup engine,
+//! driven exactly as that feed drives it.
+//!
+//! The asymmetry these tests pin is the reference's: **Yes sends, No does not**. There is no
+//! decline packet for an escort confirm, so a test that only checked "the popup closes" would pass
+//! against a version that silently answered No on the wire.
+
+use benilla_ui::script::{ScriptValue, UiScript};
+
+/// Load one shipped `assets/ui/` into `s`, panicking on any loader error (the duel tests'
+/// loader, duplicated so this file is self-contained).
+fn load_xml(s: &UiScript, file: &str) -> usize {
+ 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
+ );
+ report.frames
+}
+
+fn setup() -> UiScript {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_xml(&s, "Fonts.xml");
+ load_xml(&s, "MoneyFrame.xml");
+ load_xml(&s, "UiPanels.xml");
+ load_xml(&s, "QuestShareFrame.xml");
+ s
+}
+
+fn confirm(s: &mut UiScript, who: &str, quest: &str) {
+ s.fire_event(
+ "QUEST_ACCEPT_CONFIRM",
+ vec![
+ ScriptValue::Str(who.to_string()),
+ ScriptValue::Str(quest.to_string()),
+ ],
+ );
+}
+
+/// The event raises the popup with BOTH args filled, in the reference's order: the player first,
+/// the quest title second (`QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"`). A
+/// swap would read "Escort Duty is starting Thrall", which is exactly the kind of wrong that looks
+/// right at a glance.
+#[test]
+fn the_confirm_names_the_player_first_and_the_quest_second() {
+ let mut s = setup();
+ confirm(&mut s, "Thrall", "Escort Duty");
+ assert!(
+ s.eval::("return StaticPopup1:IsVisible()").unwrap(),
+ "QUEST_ACCEPT_CONFIRM shows the confirm popup"
+ );
+ assert_eq!(
+ s.eval::("return StaticPopup1Text:GetText()")
+ .unwrap(),
+ "Thrall is starting Escort Duty\nWould you like to as well?"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// Yes queues exactly one `ConfirmAcceptQuest()` and closes the popup.
+#[test]
+fn yes_answers_once() {
+ let mut s = setup();
+ confirm(&mut s, "Thrall", "Escort Duty");
+ s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
+ assert_eq!(s.take_quest_confirms(), 1);
+ assert_eq!(s.take_quest_confirms(), 0, "drained");
+ assert!(
+ !s.eval::("return StaticPopup1:IsVisible()").unwrap(),
+ "answering closes it"
+ );
+}
+
+/// **No sends nothing.** The reference's `QUEST_ACCEPT` entry has an `OnAccept` and no `OnCancel`
+/// at all (`StaticPopup.lua:727-737`), because there is no decline packet — the server's pending
+/// latch is cleared by the next thing that touches it. Same for ESC.
+#[test]
+fn no_and_escape_send_nothing() {
+ let mut s = setup();
+ confirm(&mut s, "Thrall", "Escort Duty");
+ s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
+ assert_eq!(s.take_quest_confirms(), 0, "No must not answer the server");
+ assert!(!s.eval::("return StaticPopup1:IsVisible()").unwrap());
+
+ confirm(&mut s, "Thrall", "Escort Duty");
+ s.run("StaticPopup_EscapePressed()").unwrap();
+ assert_eq!(s.take_quest_confirms(), 0, "ESC must not answer the server");
+ assert!(!s.eval::("return StaticPopup1:IsVisible()").unwrap());
+}
+
+/// A second confirm replaces the first rather than stacking — the server keeps ONE share latch per
+/// player, so an older question is already dead by the time a newer one arrives, and answering the
+/// stale popup would send the wrong quest id.
+#[test]
+fn a_second_confirm_replaces_the_first() {
+ let mut s = setup();
+ confirm(&mut s, "Thrall", "Escort Duty");
+ confirm(&mut s, "Jaina", "Deeper Still");
+ assert_eq!(
+ s.eval::("return StaticPopup1Text:GetText()")
+ .unwrap(),
+ "Jaina is starting Deeper Still\nWould you like to as well?"
+ );
+ assert!(
+ !s.eval::("return StaticPopup2:IsVisible()").unwrap(),
+ "the confirm is exclusive — it reuses the one slot"
+ );
+}
diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs
index dfd6acce..4a600800 100644
--- a/crates/benilla-app/src/ui_script/questlog_tests.rs
+++ b/crates/benilla-app/src/ui_script/questlog_tests.rs
@@ -4,8 +4,8 @@
//! `quest_tests.rs`/`bag_tests.rs`'s engine-only harness for the quest-log slice (decision 0088 arc).
use benilla_ui::script::{
- ExtractedQuad, QuadContent, QuestItemView, QuestLogDetail, QuestLogEntryView,
- QuestLogObjectiveView, QuestLogState, SoundRequest, UiScript,
+ ExtractedQuad, PartyMemberInfo, PartyState, QuadContent, QuestItemView, QuestLogDetail,
+ QuestLogEntryView, QuestLogObjectiveView, QuestLogState, SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/` into `s`, panicking on any loader error (the panel/quest
@@ -1172,3 +1172,144 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
assert_eq!(s.eval::("return GetQuestLogSelection()").unwrap(), 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+
+// ── The Share Quest button (decision 1733) ───────────────────────────────────────────────────────
+
+/// A party of `n` others; `GetNumPartyMembers` is that list's length.
+fn party(n: usize) -> PartyState {
+ PartyState {
+ members: (0..n)
+ .map(|i| PartyMemberInfo {
+ name: format!("Mate{i}"),
+ guid: 0x300 + i as u64,
+ })
+ .collect(),
+ ..PartyState::default()
+ }
+}
+
+/// The 8-entry fixture with `pushable` set on the entries named by `ids` — the app sets it from
+/// the cached template's `QUEST_FLAGS_SHARABLE` bit, so a fixture is how the button's predicate is
+/// exercised without a wire.
+fn entries_sharable(ids: &[u32]) -> QuestLogState {
+ let mut state = eight_entries();
+ for e in &mut state.entries {
+ e.pushable = ids.contains(&e.quest_id);
+ }
+ state
+}
+
+/// The button's predicate is a CONJUNCTION, and each half is tested by moving only itself: a
+/// sharable quest with no party is dark, a party with an unsharable selection is dark, and only
+/// both together light it (ref `QuestLogFrame.lua:299-305`).
+#[test]
+fn share_quest_needs_both_a_sharable_selection_and_a_party() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_xml(&s, "Fonts.xml");
+ load_xml(&s, "MoneyFrame.xml");
+ load_xml(&s, "UiPanels.xml");
+ load_xml(&s, "MerchantFrame.xml");
+ load_xml(&s, "QuestLogFrame.xml");
+
+ // Solo, quest 1 sharable and selected by the auto-selection.
+ s.set_quest_log(entries_sharable(&[1]));
+ s.set_party(PartyState::default());
+ s.run("ToggleQuestLog()").unwrap();
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ assert_eq!(s.eval::("return GetQuestLogSelection()").unwrap(), 1);
+ // `1`/nil, never true/false (decision 1738) — compared the way the reference's own predicate
+ // does, which is why the button below reads the same either way.
+ assert!(
+ s.eval::("return GetQuestLogPushable() ~= nil")
+ .unwrap(),
+ "quest 1 carries the sharable bit"
+ );
+ assert!(
+ !s.eval::("return QuestLogPushQuestButton:IsEnabled()")
+ .unwrap(),
+ "solo: sharable is not enough"
+ );
+
+ // Join a party — the event alone must light it, with no quest-log change at all.
+ s.set_party(party(2));
+ s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
+ assert!(
+ s.eval::("return QuestLogPushQuestButton:IsEnabled()")
+ .unwrap(),
+ "PARTY_MEMBERS_CHANGED alone re-tests the button"
+ );
+
+ // Select an unsharable quest: party unchanged, button goes dark.
+ s.run("SelectQuestLogEntry(2)").unwrap();
+ s.run("BenillaQuestLogFrame_Update()").unwrap();
+ assert!(
+ s.eval::("return GetQuestLogPushable() == nil")
+ .unwrap(),
+ "quest 2 has no sharable bit"
+ );
+ assert!(
+ !s.eval::("return QuestLogPushQuestButton:IsEnabled()")
+ .unwrap(),
+ "in a party: an unsharable selection is still dark"
+ );
+
+ // Leaving the party darkens it again from the other side.
+ s.run("SelectQuestLogEntry(1)").unwrap();
+ s.run("BenillaQuestLogFrame_Update()").unwrap();
+ assert!(s
+ .eval::("return QuestLogPushQuestButton:IsEnabled()")
+ .unwrap());
+ s.set_party(PartyState::default());
+ s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
+ assert!(!s
+ .eval::("return QuestLogPushQuestButton:IsEnabled()")
+ .unwrap());
+}
+
+/// An EMPTY log disables the button outright, ahead of both other conjuncts — the ref's own first
+/// guard, and the one that matters because `GetQuestLogPushable()` on no selection is false anyway
+/// but `GetNumPartyMembers() > 0` is not.
+#[test]
+fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_xml(&s, "Fonts.xml");
+ load_xml(&s, "MoneyFrame.xml");
+ load_xml(&s, "UiPanels.xml");
+ load_xml(&s, "MerchantFrame.xml");
+ load_xml(&s, "QuestLogFrame.xml");
+
+ s.set_quest_log(QuestLogState::default());
+ s.set_party(party(4));
+ s.run("ToggleQuestLog()").unwrap();
+ assert!(!s
+ .eval::("return QuestLogPushQuestButton:IsEnabled()")
+ .unwrap());
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// The click queues the SELECTION's quest id — not its list index, and not the row under the
+/// mouse. The fixture's ids are deliberately not equal to their 1-based positions after a
+/// selection move, so an index/id confusion shows up here.
+#[test]
+fn share_quest_click_queues_the_selected_quests_id() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_xml(&s, "Fonts.xml");
+ load_xml(&s, "MoneyFrame.xml");
+ load_xml(&s, "UiPanels.xml");
+ load_xml(&s, "MerchantFrame.xml");
+ load_xml(&s, "QuestLogFrame.xml");
+
+ s.set_quest_log(entries_sharable(&[1, 5]));
+ s.set_party(party(1));
+ s.run("ToggleQuestLog()").unwrap();
+
+ s.run("SelectQuestLogEntry(5)").unwrap();
+ s.run("BenillaQuestLogFrame_Update()").unwrap();
+ s.run("QuestLogPushQuestButton:Click()").unwrap();
+ assert_eq!(s.take_quest_log_pushes(), vec![5]);
+ assert!(s.take_quest_log_pushes().is_empty(), "drained");
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs
index 9f5bb57f..746d58f7 100644
--- a/crates/benilla-app/src/ui_session.rs
+++ b/crates/benilla-app/src/ui_session.rs
@@ -57,6 +57,17 @@ pub(crate) trait NpcSession: Resource {
fn npc(&self) -> Option;
/// The window's client-side close (no packet) — the same clear its close button does.
fn close(&mut self);
+ /// Whether the OPEN session is subject to the service-range guard below. Almost always yes,
+ /// which is why it is defaulted — but "bound to a live unit" and "must stay inside NPC service
+ /// range" are two claims, and a party quest-share separates them: its giver is a live *player*
+ /// with a name and a portrait (so [`Self::npc`] must report it) whose distance rule is the
+ /// server's 14 yd share bound, not this 5.56 yd counter reach (decision 1733).
+ ///
+ /// Contrast [`crate::ui_quest::QuestGiver`]'s item filter, which answers the *other* question
+ /// by returning `None`: a quest-starter item is not a unit at all (decision 0664).
+ fn range_guarded(&self) -> bool {
+ true
+ }
}
/// Did an NPC-bound window switch to a **different** NPC of the same kind while it was already open
@@ -78,6 +89,9 @@ pub(crate) fn close_npc_session_out_of_range(
transforms: Query<&Transform>,
) {
let Some(npc) = session.npc() else { return };
+ if !session.range_guarded() {
+ return;
+ }
let Some(self_tf) = self_q.iter().next() else {
return;
};
@@ -208,6 +222,89 @@ mod tests {
assert!(!app.world().resource::().is_open());
}
+ /// **A party quest-share is exempt from the guard, and still a full session.** The two are one
+ /// test because they are the two halves of decision 1733's split: the giver is a live player,
+ /// so `npc()` must keep reporting it (the QuestFrame portrait and name banner read it through
+ /// [`InteractNpc`]) — while the *distance* rule is the server's 14 yd share bound, not this
+ /// 5.56 yd NPC-counter reach. Before the split, a sharer standing six yards off had the panel
+ /// yanked away from a quest the server would have granted.
+ #[test]
+ fn a_shared_quest_panel_survives_the_service_range_but_stays_a_session() {
+ use crate::ui_quest::{QuestGiver, QuestView};
+
+ // The panel's wire view, whichever giver opened it — only the GUID differs between an NPC
+ // offer and a share, which is the whole point.
+ fn detail(npc: u64) -> QuestView {
+ QuestView::Detail(benilla_protocol::messages::QuestDetails {
+ npc,
+ quest_id: 1,
+ title: "A Threat Within".into(),
+ details: String::new(),
+ objectives: String::new(),
+ auto_finish: 1,
+ choices: Vec::new(),
+ rewards: Vec::new(),
+ money: 0,
+ reward_spell: 0,
+ })
+ }
+
+ const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
+ const VENDOR: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
+
+ let mut app = App::new();
+ app.init_resource::();
+ app.init_resource::();
+ app.init_resource::();
+ app.add_systems(
+ Update,
+ (
+ close_npc_session_out_of_range::,
+ feed_interact_npc,
+ )
+ .chain(),
+ );
+ app.world_mut()
+ .spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
+ // Ten yards away: well past the NPC service gate, well inside the share bound.
+ let sharer = app
+ .world_mut()
+ .spawn(Transform::from_xyz(10.0, 0.0, 0.0))
+ .id();
+ app.world_mut()
+ .resource_mut::()
+ .0
+ .insert(SHARER, sharer);
+
+ app.world_mut()
+ .resource_mut::()
+ .open(SHARER, detail(SHARER));
+ app.update();
+ assert!(
+ app.world().resource::().is_open(),
+ "a shared quest's panel is not NPC-range-guarded"
+ );
+ assert_eq!(
+ app.world().resource::().1,
+ Some(SHARER),
+ "it is still a session: the panel's portrait and name banner point at the sharer"
+ );
+
+ // The control: the identical distance with an NPC giver closes, as it always has.
+ app.world_mut()
+ .resource_mut::()
+ .0
+ .insert(VENDOR, sharer);
+ app.world_mut()
+ .resource_mut::()
+ .open(VENDOR, detail(VENDOR));
+ app.update();
+ assert!(
+ !app.world().resource::().is_open(),
+ "an NPC giver ten yards off still closes"
+ );
+ }
+
/// The `"npc"` portrait token's resolver ([`feed_interact_npc`]): the open NPC session's guid,
/// mapped through the [`GuidIndex`] to its world entity. No window → None; an unindexed
/// (not-yet-streamed) NPC → None, never a stale entity; the gossip session wins the `.or` chain
diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs
index 8b80b655..4f84c3b1 100644
--- a/crates/benilla-protocol/src/events.rs
+++ b/crates/benilla-protocol/src/events.rs
@@ -19,11 +19,11 @@ use crate::messages::{
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MonsterMoveFacing,
ObjectFields, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures,
- PetitionSignResults, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
- QuestRequestItems, QuestTemplate, SpellDamageLog, SpellDispelLog, SpellEnergizeLog,
- SpellHealLog, SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, StabledPet,
- TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
- WhoResults, XpGain,
+ PetitionSignResults, PvpCredit, QuestComplete, QuestConfirmAccept, QuestDetails,
+ QuestGiverList, QuestOfferReward, QuestRequestItems, QuestShareMsg, QuestTemplate,
+ SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellHealLog, SpellInstaKillLog,
+ SpellLogExecute, SpellLogMiss, SpellOutcomeLog, StabledPet, TaxiMask, TradeStatus,
+ TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
};
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
@@ -850,6 +850,13 @@ pub enum SessionEvent {
QuestFailed { quest_id: u32, timed: bool },
/// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`).
QuestLogFull,
+ /// One party member's verdict on a quest WE shared (`MSG_QUEST_PUSH_RESULT`) — decision 1733.
+ /// `member` is the member the verdict is about (never the sharer; see the direction trap on
+ /// [`crate::messages::QuestPushResult`]), and fills the `%s` of the `ERR_QUEST_PUSH_*` line.
+ QuestPushResult { member: u64, msg: QuestShareMsg },
+ /// A party member started a `QUEST_FLAGS_PARTY_ACCEPT` (escort) quest and the server is asking
+ /// whether we want it too (`SMSG_QUEST_CONFIRM_ACCEPT`) — the `QUEST_ACCEPT` confirm box.
+ QuestConfirmAccept(QuestConfirmAccept),
/// The giver won't OFFER the quest (`SMSG_QUESTGIVER_QUEST_INVALID`): one `QuestFailedReason`
/// msg code and no quest id — vmangos' `SendCanTakeQuestResponse`, the answer to a query or an
/// accept that fails `CanTakeQuest` ("already on that quest", "not high enough level").
diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs
index 8e15e475..2222f88e 100644
--- a/crates/benilla-protocol/src/events/decode.rs
+++ b/crates/benilla-protocol/src/events/decode.rs
@@ -347,6 +347,11 @@ pub fn decode(packet: ServerPacket) -> Vec {
timed: true,
}],
ServerPacket::QuestLogFull => vec![SessionEvent::QuestLogFull],
+ ServerPacket::QuestPushResult(r) => vec![SessionEvent::QuestPushResult {
+ member: r.member,
+ msg: r.msg,
+ }],
+ ServerPacket::QuestConfirmAccept(c) => vec![SessionEvent::QuestConfirmAccept(c)],
ServerPacket::QuestGiverInvalid { msg } => {
vec![SessionEvent::QuestGiverInvalid { reason: msg }]
}
diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs
index 443d79c4..39482a09 100644
--- a/crates/benilla-protocol/src/messages/mod.rs
+++ b/crates/benilla-protocol/src/messages/mod.rs
@@ -188,12 +188,13 @@ pub use progression::{
};
pub use pvp::{inspect_honor_stats, InspectHonorStats, PvpCredit};
pub use quest::{
- dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward,
- questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward,
- questgiver_status_query, questlog_remove_quest, questlog_swap_quest, QuestComplete,
- QuestDetails, QuestGiverList, QuestListEntry, QuestObjective, QuestOfferReward,
- QuestRequestItems, QuestRequiredItem, QuestRewardItem, QuestTemplate, QUEST_EMOTE_COUNT,
- QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT,
+ dialog_status, push_quest_to_party, quest_confirm_accept, quest_flags, quest_push_result,
+ quest_query, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest,
+ questgiver_hello, questgiver_query_quest, questgiver_request_reward, questgiver_status_query,
+ questlog_remove_quest, questlog_swap_quest, QuestComplete, QuestConfirmAccept, QuestDetails,
+ QuestGiverList, QuestListEntry, QuestObjective, QuestOfferReward, QuestPushResult,
+ QuestRequestItems, QuestRequiredItem, QuestRewardItem, QuestShareMsg, QuestTemplate,
+ QUEST_EMOTE_COUNT, QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT,
};
pub use reputation::{
set_faction_at_war, set_faction_inactive, set_watched_faction, WATCHED_FACTION_NONE,
diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs
index 78bbf8a0..9ac56dc8 100644
--- a/crates/benilla-protocol/src/messages/opcode.rs
+++ b/crates/benilla-protocol/src/messages/opcode.rs
@@ -689,6 +689,15 @@ pub const SMSG_QUESTUPDATE_COMPLETE: u16 = 0x0198; // 408
pub const SMSG_QUESTUPDATE_ADD_KILL: u16 = 0x0199; // 409
pub const SMSG_QUESTUPDATE_ADD_ITEM: u16 = 0x019A; // 410
+// The party quest-**share** set (VERIFIED vmangos `Opcodes_1_12_1.h`: 411-413 + 630) — decision
+// 1733. `MSG_QUEST_PUSH_RESULT` is the one bidirectional opcode of the quest family: the receiver
+// sends its own verdict up, the server relays every verdict (its own and the receiver's) back down
+// to the SHARER. Bodies in [`super::quest`]'s `share` submodule.
+pub const CMSG_QUEST_CONFIRM_ACCEPT: u16 = 0x019B; // 411
+pub const SMSG_QUEST_CONFIRM_ACCEPT: u16 = 0x019C; // 412
+pub const CMSG_PUSHQUESTTOPARTY: u16 = 0x019D; // 413
+pub const MSG_QUEST_PUSH_RESULT: u16 = 0x0276; // 630
+
pub const CMSG_LIST_INVENTORY: u16 = 0x019E; // 414
pub const SMSG_LIST_INVENTORY: u16 = 0x019F; // 415
pub const CMSG_SELL_ITEM: u16 = 0x01A0; // 416
diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs
index 9135576f..138a1f42 100644
--- a/crates/benilla-protocol/src/messages/packet.rs
+++ b/crates/benilla-protocol/src/messages/packet.rs
@@ -14,12 +14,13 @@ use super::{
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MoveMode, Object, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog,
PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList,
- PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestDetails,
- QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
- ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog,
- SpellDispelLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellInstaKillLog, SpellLogExecute,
- SpellLogMiss, SpellOutcomeLog, SpellStart, StabledPet, TaxiMask, TradeStatus,
- TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
+ PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestConfirmAccept,
+ QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestPushResult,
+ QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets,
+ SpellCooldown, SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellGo, SpellHealLog,
+ SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, SpellStart, StabledPet,
+ TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
+ WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -747,6 +748,14 @@ pub enum ServerPacket {
/// `SMSG_QUESTLOG_FULL` — the log has no free slot for a new quest; empty body (vmangos
/// `Quest.cpp:87`).
QuestLogFull,
+ /// `MSG_QUEST_PUSH_RESULT` — one party member's verdict on a quest we shared, relayed to the
+ /// SHARER (layout + the direction trap on [`QuestPushResult`], decision 1733). Arrives once per
+ /// member per push, and more than once per member: the server opens with `SHARING_QUEST` and
+ /// then sends the real outcome.
+ QuestPushResult(QuestPushResult),
+ /// `SMSG_QUEST_CONFIRM_ACCEPT` — a party member started a `QUEST_FLAGS_PARTY_ACCEPT` (escort)
+ /// quest and we are being asked whether to start it too (layout in [`QuestConfirmAccept`]).
+ QuestConfirmAccept(QuestConfirmAccept),
/// `SMSG_QUESTUPDATE_COMPLETE` — every objective on this quest is now complete (vmangos
/// `Quest.cpp:91`); the log slot's state byte gets `QUEST_STATE_COMPLETE`.
QuestUpdateComplete {
@@ -1516,6 +1525,8 @@ impl ServerPacket {
ServerPacket::QuestGiverFailed { .. } => "SMSG_QUESTGIVER_QUEST_FAILED".into(),
ServerPacket::QuestQueryResponse(_) => "SMSG_QUEST_QUERY_RESPONSE".into(),
ServerPacket::QuestLogFull => "SMSG_QUESTLOG_FULL".into(),
+ ServerPacket::QuestPushResult(_) => "MSG_QUEST_PUSH_RESULT".into(),
+ ServerPacket::QuestConfirmAccept(_) => "SMSG_QUEST_CONFIRM_ACCEPT".into(),
ServerPacket::QuestUpdateComplete { .. } => "SMSG_QUESTUPDATE_COMPLETE".into(),
ServerPacket::QuestUpdateFailed { .. } => "SMSG_QUESTUPDATE_FAILED".into(),
ServerPacket::QuestUpdateFailedTimer { .. } => "SMSG_QUESTUPDATE_FAILEDTIMER".into(),
diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs
index e9cc2513..8446a9d4 100644
--- a/crates/benilla-protocol/src/messages/parse.rs
+++ b/crates/benilla-protocol/src/messages/parse.rs
@@ -665,6 +665,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result {
ServerPacket::QuestQueryResponse(Box::new(quest::read_quest_query_response(&mut r)?))
}
opcode::SMSG_QUESTLOG_FULL => ServerPacket::QuestLogFull,
+ opcode::MSG_QUEST_PUSH_RESULT => {
+ ServerPacket::QuestPushResult(quest::read_quest_push_result(&mut r)?)
+ }
+ opcode::SMSG_QUEST_CONFIRM_ACCEPT => {
+ ServerPacket::QuestConfirmAccept(quest::read_quest_confirm_accept(&mut r)?)
+ }
opcode::SMSG_QUESTUPDATE_COMPLETE => ServerPacket::QuestUpdateComplete {
quest_id: quest::read_quest_update_complete(&mut r)?,
},
diff --git a/crates/benilla-protocol/src/messages/quest/log.rs b/crates/benilla-protocol/src/messages/quest/log.rs
index 008e6c4e..3f5753dc 100644
--- a/crates/benilla-protocol/src/messages/quest/log.rs
+++ b/crates/benilla-protocol/src/messages/quest/log.rs
@@ -50,6 +50,38 @@ pub struct QuestObjective {
pub text: String,
}
+/// `QuestFlags` (vmangos `QuestDef.h:145-160`) — the [`QuestTemplate::flags`] word, which is the
+/// only place the client learns a quest's *kind* (the giver panels carry text and rewards, never
+/// flags). Two of these are load-bearing for the party share (decision 1733) and the rest are
+/// pinned beside them so the word is documented once:
+///
+/// - [`PARTY_ACCEPT`] — an escort quest. When a party member accepts one, every other eligible
+/// member gets `SMSG_QUEST_CONFIRM_ACCEPT`.
+/// - [`SHARABLE`] — the quest log's *Share Quest* button. **The client owns this test**: the
+/// server's `HandlePushQuestToParty` does not check the bit at all, it only re-tests it when the
+/// receiver accepts (`Player::CanShareQuest`, `Player.cpp:13959`).
+/// - [`HIDDEN_REWARDS`] documents a wire shape this parser already survives (see the module's
+/// uniform-rewards note): the rewards block is written as three zero `u32`s rather than omitted.
+pub mod quest_flags {
+ /// Not used by 1.12 data.
+ pub const STAY_ALIVE: u32 = 0x0000_0001;
+ /// Escort: accepting it asks the rest of the party whether they want it too.
+ pub const PARTY_ACCEPT: u32 = 0x0000_0002;
+ /// Not used by 1.12 data.
+ pub const EXPLORATION: u32 = 0x0000_0004;
+ /// The quest may be shared with the party.
+ pub const SHARABLE: u32 = 0x0000_0008;
+ /// Not used by 1.12 data.
+ pub const EPIC: u32 = 0x0000_0020;
+ /// Not used by 1.12 data.
+ pub const RAID: u32 = 0x0000_0040;
+ /// Rewards ship only in `SMSG_QUESTGIVER_OFFER_REWARD` — the DETAILS panel and this template
+ /// write an empty rewards block instead.
+ pub const HIDDEN_REWARDS: u32 = 0x0000_0200;
+ /// Granted and completed in one step; never appears in the client's quest log.
+ pub const AUTO_REWARDED: u32 = 0x0000_0400;
+}
+
/// `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch — live on 5875):
/// the full quest template, answering `CMSG_QUEST_QUERY` and feeding the quest log's detail view.
/// Unlike the giver panels this carries **fixed-count** reward/choice/objective arrays (no count
diff --git a/crates/benilla-protocol/src/messages/quest/mod.rs b/crates/benilla-protocol/src/messages/quest/mod.rs
index 203d9b58..f9056d8e 100644
--- a/crates/benilla-protocol/src/messages/quest/mod.rs
+++ b/crates/benilla-protocol/src/messages/quest/mod.rs
@@ -4,12 +4,16 @@
//! `SMSG_QUEST_QUERY_RESPONSE` (92/93), `CMSG_QUESTLOG_SWAP_QUEST`/`CMSG_QUESTLOG_REMOVE_QUEST`/
//! `SMSG_QUESTLOG_FULL` (403-405), and the `SMSG_QUESTUPDATE_*` progress pushes (406-410). CMSG
//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's
-//! `AppendBodyTo` writers (line citations inline in each submodule). Out of scope for both slices:
+//! `AppendBodyTo` writers (line citations inline in each submodule). The third slice — `share`,
//! `CMSG_QUEST_CONFIRM_ACCEPT` / `SMSG_QUEST_CONFIRM_ACCEPT` / `CMSG_PUSHQUESTTOPARTY` /
-//! `MSG_QUEST_PUSH_RESULT` — the party quest-share flow, a separate solo slice.
+//! `MSG_QUEST_PUSH_RESULT` (411-413 + 630) — is the party quest-share flow (decision 1733). It is
+//! its own file and not `giver`'s, even though a shared quest lands on the giver's own DETAILS
+//! panel: what is new here is the *verdict* wire between sharer and receiver, and `giver` never
+//! learns that a share exists — it only ever sees a giver guid that happens to be a player.
mod giver;
mod log;
+mod share;
pub use giver::{
dialog_status, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest,
@@ -18,8 +22,12 @@ pub use giver::{
QuestRequestItems, QuestRequiredItem, QuestRewardItem, QUEST_EMOTE_COUNT,
};
pub use log::{
- quest_query, questlog_remove_quest, questlog_swap_quest, QuestObjective, QuestTemplate,
- QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT,
+ quest_flags, quest_query, questlog_remove_quest, questlog_swap_quest, QuestObjective,
+ QuestTemplate, QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT,
+};
+pub use share::{
+ push_quest_to_party, quest_confirm_accept, quest_push_result, QuestConfirmAccept,
+ QuestPushResult, QuestShareMsg,
};
pub(super) use giver::{
@@ -31,3 +39,4 @@ pub(super) use log::{
read_quest_query_response, read_quest_update_add_item, read_quest_update_add_kill,
read_quest_update_complete, read_quest_update_failed, read_quest_update_failedtimer,
};
+pub(super) use share::{read_quest_confirm_accept, read_quest_push_result};
diff --git a/crates/benilla-protocol/src/messages/quest/share.rs b/crates/benilla-protocol/src/messages/quest/share.rs
new file mode 100644
index 00000000..f2e07e7b
--- /dev/null
+++ b/crates/benilla-protocol/src/messages/quest/share.rs
@@ -0,0 +1,199 @@
+//! The **party quest-share** wire (opcodes 411-413 + 630, VERIFIED vmangos `Opcodes_1_12_1.h`) —
+//! decision 1733, the third slice of the quest arc after [`super::giver`] and [`super::log`].
+//!
+//! Two independent flows share this file because they share the same server-side latch (vmangos
+//! `Player::SetQuestShareInfo`/`ClearQuestShareInfo`, one `{sharerGuid, questId}` pair per player):
+//!
+//! 1. **The push** — the quest log's *Share Quest* button. `CMSG_PUSHQUESTTOPARTY{questId}` → the server
+//! walks the group and, per member, sends the SHARER a `MSG_QUEST_PUSH_RESULT`
+//! verdict; an eligible member additionally gets an ordinary `SMSG_QUESTGIVER_QUEST_DETAILS`
+//! whose `npcGuid` is the **sharer's player guid** (vmangos `QuestHandler.cpp:403-459`). That is
+//! the whole reason the receiver needs no new inbound opcode: a shared quest arrives on the
+//! detail panel already built, and Accept answers it with the ordinary
+//! `CMSG_QUESTGIVER_ACCEPT_QUEST` addressed to that player guid (the server's own
+//! `TYPEID_PLAYER` arm, `QuestHandler.cpp:111-114`, gated on `Player::CanShareQuest`).
+//! A **decline** is the one verdict the client itself originates: `MSG_QUEST_PUSH_RESULT` back
+//! up, which the server relays to the sharer with the *receiver's* guid substituted
+//! (`QuestHandler.cpp:461-478`).
+//! 2. **The escort confirm** — `QUEST_FLAGS_PARTY_ACCEPT` (0x2). When a party member accepts such
+//! a quest, every other eligible member gets `SMSG_QUEST_CONFIRM_ACCEPT` and answers with
+//! `CMSG_QUEST_CONFIRM_ACCEPT` if they say yes (vmangos `QuestHandler.cpp:172-193`,
+//! `Player.cpp:14571-14594`). Saying *no* sends nothing at all — the server's latch is cleared
+//! by the next thing that touches it.
+//!
+//! **Who the guid names flips direction on `MSG_QUEST_PUSH_RESULT`, and that is the one trap here.**
+//! The opcode is `MSG_*` — the same number both ways — but the `u64` never means the same thing
+//! twice: going *up* it is the sharer the receiver is answering; coming *down* it is the OTHER
+//! party (`Player::SendPushToPartyResponse` writes `pPlayer`'s guid, the member the verdict is
+//! *about*, `Player.cpp:14596-14608`), so the sharer's `%s` fills from it directly.
+
+use std::io;
+
+use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8};
+
+/// `QuestShareMessages` (vmangos `QuestDef.h:62-70`) — the `u8` verdict `MSG_QUEST_PUSH_RESULT`
+/// carries in both directions. The server originates 0/1/4/5/6/7/8 while walking the group, and 2
+/// when the receiver accepts; the client originates 3 when the receiver declines.
+///
+/// Kept as a plain `u8` newtype rather than an enum with a `TryFrom` because the *display* mapping
+/// (which `ERR_QUEST_PUSH_*` message id each value selects, and on which surface) belongs to the
+/// app layer's message table, not to the wire — and an unknown value must survive the parse the way
+/// every other unmapped code in this crate does, not become a parse error that drops the packet.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct QuestShareMsg(pub u8);
+
+impl QuestShareMsg {
+ /// `QUEST_PARTY_MSG_SHARING_QUEST` — the server's opening "the push went out to this member".
+ pub const SHARING_QUEST: Self = Self(0);
+ /// `QUEST_PARTY_MSG_CANT_TAKE_QUEST` — the member fails `CanTakeQuest` (level, race, prereqs).
+ pub const CANT_TAKE_QUEST: Self = Self(1);
+ /// `QUEST_PARTY_MSG_ACCEPT_QUEST` — the member accepted (server-originated, on their
+ /// `CMSG_QUESTGIVER_ACCEPT_QUEST`).
+ pub const ACCEPT_QUEST: Self = Self(2);
+ /// `QUEST_PARTY_MSG_DECLINE_QUEST` — **the one value the client sends**.
+ pub const DECLINE_QUEST: Self = Self(3);
+ /// `QUEST_PARTY_MSG_TOO_FAR` — outside `QUEST_SHARE_DISTANCE` (14.0 yd, vmangos `Object.h:72`).
+ pub const TOO_FAR: Self = Self(4);
+ /// `QUEST_PARTY_MSG_BUSY` — the member already has a share latched.
+ pub const BUSY: Self = Self(5);
+ /// `QUEST_PARTY_MSG_LOG_FULL` — the member's quest log is full.
+ pub const LOG_FULL: Self = Self(6);
+ /// `QUEST_PARTY_MSG_HAVE_QUEST` — the member is already on the quest.
+ pub const HAVE_QUEST: Self = Self(7);
+ /// `QUEST_PARTY_MSG_FINISH_QUEST` — the member has already completed the quest.
+ pub const FINISH_QUEST: Self = Self(8);
+}
+
+/// `MSG_QUEST_PUSH_RESULT` as the **server** sends it (vmangos
+/// `Server/Packets/Quest.cpp:81-85`): `u64 guid, u8 msg`.
+///
+/// `guid` is the party member the verdict is *about* — never the sharer, and never us (see the
+/// module header's direction trap). It is what fills the `%s` of every `ERR_QUEST_PUSH_*` string.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct QuestPushResult {
+ /// The party member the verdict concerns.
+ pub member: u64,
+ /// The verdict.
+ pub msg: QuestShareMsg,
+}
+
+/// `SMSG_QUEST_CONFIRM_ACCEPT` (vmangos `Server/Packets/Quest.cpp:131-136`): `u32 questId, cstr
+/// questTitle, u64 senderGuid` — the `QUEST_FLAGS_PARTY_ACCEPT` (escort) confirm box.
+///
+/// The **title travels on the wire** rather than being looked up from the client's template cache,
+/// because the server localizes it per receiver session (`Player::SendQuestConfirmAccept`,
+/// `Player.cpp:14575-14586`) — and because the receiver may never have queried this quest at all.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct QuestConfirmAccept {
+ /// The quest being started — echoed back verbatim in [`quest_confirm_accept`].
+ pub quest_id: u32,
+ /// The quest title, already localized by the server.
+ pub title: String,
+ /// The party member who accepted it — the first `%s` of `QUEST_ACCEPT`.
+ pub sender: u64,
+}
+
+// ── CMSG encoders ─────────────────────────────────────────────────────────────────────────────────
+
+/// Body of `CMSG_PUSHQUESTTOPARTY` (vmangos `Quest.cpp:68-71`): one `u32` quest id. The quest log's
+/// *Share Quest* button; the server addresses the group itself, so no member guid rides along.
+pub fn push_quest_to_party(quest_id: u32) -> Vec {
+ quest_id.to_le_bytes().to_vec()
+}
+
+/// Body of `CMSG_QUEST_CONFIRM_ACCEPT` (vmangos `Quest.cpp:63-66`): one `u32` quest id — the
+/// escort confirm's Yes.
+pub fn quest_confirm_accept(quest_id: u32) -> Vec {
+ quest_id.to_le_bytes().to_vec()
+}
+
+/// Body of `MSG_QUEST_PUSH_RESULT` as the **client** sends it (vmangos `Quest.cpp:73-77`): `u64
+/// guid, u8 msg`. `guid` is the *sharer* we are answering — the `npcGuid` the shared
+/// `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under.
+///
+/// vmangos ignores the guid entirely (it re-derives the sharer from its own latch,
+/// `QuestHandler.cpp:461-467`), so a wrong value here would go unnoticed against this server. It is
+/// sent correctly anyway: the field is the protocol's, not the server's.
+pub fn quest_push_result(sharer: u64, msg: QuestShareMsg) -> Vec {
+ let mut b = Vec::with_capacity(9);
+ b.extend_from_slice(&sharer.to_le_bytes());
+ b.push(msg.0);
+ b
+}
+
+// ── SMSG readers ──────────────────────────────────────────────────────────────────────────────────
+
+/// Read `MSG_QUEST_PUSH_RESULT` (see [`QuestPushResult`]).
+pub(in crate::messages) fn read_quest_push_result(r: &mut &[u8]) -> io::Result {
+ Ok(QuestPushResult {
+ member: read_u64_le(r)?,
+ msg: QuestShareMsg(read_u8(r)?),
+ })
+}
+
+/// Read `SMSG_QUEST_CONFIRM_ACCEPT` (see [`QuestConfirmAccept`]).
+pub(in crate::messages) fn read_quest_confirm_accept(
+ r: &mut &[u8],
+) -> io::Result {
+ Ok(QuestConfirmAccept {
+ quest_id: read_u32_le(r)?,
+ title: read_cstring(r)?,
+ sender: read_u64_le(r)?,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn push_and_confirm_bodies_are_bare_quest_ids() {
+ assert_eq!(
+ push_quest_to_party(0x0123_4567),
+ vec![0x67, 0x45, 0x23, 0x01]
+ );
+ assert_eq!(quest_confirm_accept(9), vec![9, 0, 0, 0]);
+ }
+
+ #[test]
+ fn push_result_body_is_guid_then_msg() {
+ let b = quest_push_result(0x0000_0000_0000_002A, QuestShareMsg::DECLINE_QUEST);
+ assert_eq!(b, vec![0x2A, 0, 0, 0, 0, 0, 0, 0, 3]);
+ }
+
+ #[test]
+ fn push_result_reads_member_then_msg() {
+ // The server's `AppendBodyTo`: guid first, verdict byte last.
+ let mut b = 0x0000_0000_0000_00AAu64.to_le_bytes().to_vec();
+ b.push(8);
+ let r = read_quest_push_result(&mut b.as_slice()).unwrap();
+ assert_eq!(
+ r,
+ QuestPushResult {
+ member: 0xAA,
+ msg: QuestShareMsg::FINISH_QUEST,
+ }
+ );
+ }
+
+ /// An unmapped verdict byte must survive the parse — the display table decides what (if
+ /// anything) to show, exactly as every other unmapped wire code in this crate is handled.
+ #[test]
+ fn unknown_verdict_byte_parses() {
+ let mut b = 1u64.to_le_bytes().to_vec();
+ b.push(0x7F);
+ let r = read_quest_push_result(&mut b.as_slice()).unwrap();
+ assert_eq!(r.msg, QuestShareMsg(0x7F));
+ }
+
+ #[test]
+ fn confirm_accept_reads_id_title_sender() {
+ let mut b = 1234u32.to_le_bytes().to_vec();
+ b.extend_from_slice(b"Escort Duty\0");
+ b.extend_from_slice(&0xF130_0000_0000_0001u64.to_le_bytes());
+ let c = read_quest_confirm_accept(&mut b.as_slice()).unwrap();
+ assert_eq!(c.quest_id, 1234);
+ assert_eq!(c.title, "Escort Duty");
+ assert_eq!(c.sender, 0xF130_0000_0000_0001);
+ }
+}
diff --git a/crates/benilla-protocol/src/world/writer/quest.rs b/crates/benilla-protocol/src/world/writer/quest.rs
index 523f6fb1..03befbaa 100644
--- a/crates/benilla-protocol/src/world/writer/quest.rs
+++ b/crates/benilla-protocol/src/world/writer/quest.rs
@@ -16,6 +16,17 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
+ /// Greet a questgiver (`CMSG_QUESTGIVER_HELLO`, layout in [`messages::questgiver_hello`]) —
+ /// answered with `SMSG_QUESTGIVER_QUEST_LIST` (or the gossip menu, server's choice). Sent by
+ /// the *end* of a quest session on a non-gossip NPC, which is how the reference puts the
+ /// player back in the giver's list after a decline (decision 1738).
+ pub fn questgiver_hello(&mut self, npc: u64) -> Result<()> {
+ self.send(
+ opcode::CMSG_QUESTGIVER_HELLO,
+ &messages::questgiver_hello(npc),
+ )
+ }
+
/// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`, layout in
/// [`messages::questgiver_query_quest`]) — the click a greeting/gossip quest row makes.
/// Answered by `SMSG_QUESTGIVER_QUEST_DETAILS` (a `QuestDetail` event).
@@ -94,4 +105,38 @@ impl WorldWriter {
&messages::questlog_remove_quest(slot),
)
}
+
+ /// Share the quest with the party (`CMSG_PUSHQUESTTOPARTY`, layout in
+ /// [`messages::push_quest_to_party`]) — the quest log's *Share Quest* button (decision 1733).
+ /// No member guid: the server walks the group itself and answers with one
+ /// `MSG_QUEST_PUSH_RESULT` per member (often two — the `SHARING_QUEST` opener, then the
+ /// outcome).
+ pub fn push_quest_to_party(&mut self, quest_id: u32) -> Result<()> {
+ self.send(
+ opcode::CMSG_PUSHQUESTTOPARTY,
+ &messages::push_quest_to_party(quest_id),
+ )
+ }
+
+ /// Answer the escort confirm with Yes (`CMSG_QUEST_CONFIRM_ACCEPT`, layout in
+ /// [`messages::quest_confirm_accept`]) — a `QUEST_FLAGS_PARTY_ACCEPT` quest a party member
+ /// started. Saying **No** sends nothing at all, so there is no counterpart method.
+ pub fn quest_confirm_accept(&mut self, quest_id: u32) -> Result<()> {
+ self.send(
+ opcode::CMSG_QUEST_CONFIRM_ACCEPT,
+ &messages::quest_confirm_accept(quest_id),
+ )
+ }
+
+ /// Report our verdict on a quest a party member shared with us (`MSG_QUEST_PUSH_RESULT`,
+ /// layout in [`messages::quest_push_result`]) — the receiver's leg of the share, relayed by the
+ /// server to the sharer. `sharer` is the giver guid the shared
+ /// `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under (a *player* guid, which is what makes that
+ /// panel a share).
+ pub fn quest_push_result(&mut self, sharer: u64, msg: messages::QuestShareMsg) -> Result<()> {
+ self.send(
+ opcode::MSG_QUEST_PUSH_RESULT,
+ &messages::quest_push_result(sharer, msg),
+ )
+ }
}
diff --git a/crates/benilla-protocol/tests/quest_share.rs b/crates/benilla-protocol/tests/quest_share.rs
new file mode 100644
index 00000000..032904cd
--- /dev/null
+++ b/crates/benilla-protocol/tests/quest_share.rs
@@ -0,0 +1,112 @@
+//! Golden tests for the **party quest-share** opcode family (decision 1733) — the quest log's
+//! *Share Quest* push, the escort confirm, and the two-way verdict relay. CMSG bodies are asserted
+//! byte-exact against the builder output; SMSG bodies are hand-built per the vmangos layouts (cited
+//! inline) and round-tripped through `parse_server` + `decode`. See `tests/common` for the shared
+//! `hx()` fixture helper and methodology note.
+//!
+//! The property worth a test file of its own is the **direction flip** on `MSG_QUEST_PUSH_RESULT`:
+//! one opcode number, two meanings for its `u64`. Going up it is the sharer we are answering;
+//! coming down it is the party member the verdict is *about*. Nothing in the type system can catch
+//! a swap, so it is pinned here from both ends.
+
+mod common;
+
+use benilla_protocol::events::{decode, SessionEvent};
+use benilla_protocol::messages::{self, opcode, quest_flags, QuestShareMsg};
+use benilla_protocol::ServerPacket;
+use common::hx;
+
+/// The three CMSG builders, byte-exact (vmangos `Server/Packets/Quest.cpp:63-77`).
+#[test]
+fn cmsg_bodies_golden() {
+ // CMSG_PUSHQUESTTOPARTY / CMSG_QUEST_CONFIRM_ACCEPT: one u32 quest id, nothing else — the
+ // server addresses the group itself, so no member guid rides along.
+ assert_eq!(
+ messages::push_quest_to_party(0x1234),
+ hx("34120000"),
+ "CMSG_PUSHQUESTTOPARTY body"
+ );
+ assert_eq!(
+ messages::quest_confirm_accept(0x1234),
+ hx("34120000"),
+ "CMSG_QUEST_CONFIRM_ACCEPT body"
+ );
+
+ // MSG_QUEST_PUSH_RESULT going UP: u64 guid, then the u8 verdict. The guid is the SHARER we are
+ // answering (`Quest.cpp:73-77`).
+ assert_eq!(
+ messages::quest_push_result(0x1234_5678_9abc_def0, QuestShareMsg::DECLINE_QUEST),
+ hx("f0debc9a7856341203"),
+ "MSG_QUEST_PUSH_RESULT (client) body"
+ );
+}
+
+/// `MSG_QUEST_PUSH_RESULT` coming DOWN (`Quest.cpp:81-85`, written by
+/// `Player::SendPushToPartyResponse`): the same two fields, but the guid is now the party MEMBER
+/// the verdict concerns — never the sharer, and never us.
+#[test]
+fn push_result_decodes_member_and_verdict() {
+ let body = hx("f0debc9a7856341200");
+ let p = messages::parse_server(opcode::MSG_QUEST_PUSH_RESULT, &body).unwrap();
+ match &p {
+ ServerPacket::QuestPushResult(r) => {
+ assert_eq!(r.member, 0x1234_5678_9abc_def0);
+ assert_eq!(r.msg, QuestShareMsg::SHARING_QUEST);
+ }
+ other => panic!("expected QuestPushResult, got {}", other.name()),
+ }
+ match decode(p).as_slice() {
+ [SessionEvent::QuestPushResult { member, msg }] => {
+ assert_eq!(*member, 0x1234_5678_9abc_def0);
+ assert_eq!(*msg, QuestShareMsg::SHARING_QUEST);
+ }
+ other => panic!("push result decoded to {} events", other.len()),
+ }
+}
+
+/// The whole verdict enum survives the round trip, including a value the client has no message
+/// for: an unmapped byte must reach the app as data, not kill the packet (the display table is
+/// where "show nothing" is decided).
+#[test]
+fn every_verdict_byte_round_trips() {
+ for raw in [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xFF] {
+ let mut body = 1u64.to_le_bytes().to_vec();
+ body.push(raw);
+ match messages::parse_server(opcode::MSG_QUEST_PUSH_RESULT, &body).unwrap() {
+ ServerPacket::QuestPushResult(r) => {
+ assert_eq!(r.msg, QuestShareMsg(raw), "verdict {raw}");
+ assert_eq!(r.member, 1);
+ }
+ other => panic!("verdict {raw} parsed as {}", other.name()),
+ }
+ }
+}
+
+/// `SMSG_QUEST_CONFIRM_ACCEPT` (`Quest.cpp:131-136`): `u32 questId, cstr questTitle, u64
+/// senderGuid` — note the title sits BETWEEN the two numbers, so a reader that grouped the
+/// scalars would decode the guid out of the title's tail.
+#[test]
+fn confirm_accept_decodes_id_title_sender() {
+ let mut body = 1234u32.to_le_bytes().to_vec();
+ body.extend_from_slice(b"Escort Duty\0");
+ body.extend_from_slice(&0x0000_0000_0000_002Au64.to_le_bytes());
+
+ let p = messages::parse_server(opcode::SMSG_QUEST_CONFIRM_ACCEPT, &body).unwrap();
+ match decode(p).as_slice() {
+ [SessionEvent::QuestConfirmAccept(c)] => {
+ assert_eq!(c.quest_id, 1234);
+ assert_eq!(c.title, "Escort Duty");
+ assert_eq!(c.sender, 0x2A);
+ }
+ other => panic!("confirm accept decoded to {} events", other.len()),
+ }
+}
+
+/// The two flag bits the share flow reads, pinned against vmangos `QuestDef.h:145-160`. They are
+/// the only route by which the client learns a quest is shareable or an escort — the giver panels
+/// carry no flags at all.
+#[test]
+fn share_quest_flags_are_the_documented_bits() {
+ assert_eq!(quest_flags::PARTY_ACCEPT, 0x2);
+ assert_eq!(quest_flags::SHARABLE, 0x8);
+}
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index 6bc50456..ede1e80f 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -1082,6 +1082,15 @@ pub(crate) struct Model {
pub(crate) quest_log_selection: u32,
pub(crate) quest_log_abandon_mark: u32,
pub(crate) quest_log_abandons: Vec,
+ /// Quest ids `QuestLogPushQuest()` queued for the app to push to the party (decision 1733).
+ /// **Ids, not entry indices** — unlike the abandon mark above, whose two-step confirm is the
+ /// whole reason it pins an index: a push is one click, so the id is resolved right then and a
+ /// log shuffle between click and drain cannot retarget it.
+ pub(crate) quest_log_pushes: Vec,
+ /// How many times `ConfirmAcceptQuest()` was called — the escort confirm's Yes. A counter, not
+ /// a quest id: the reference's verb takes no argument and answers whatever confirm the client
+ /// is holding, so the id lives app-side with the pending confirm (decision 1733).
+ pub(crate) quest_confirms: u32,
/// The shared item-template store: `item id → full tooltip view` ([`item_stats`] module doc,
/// decision 0274 P1).
pub(crate) item_templates: HashMap,
@@ -1669,6 +1678,8 @@ impl Model {
quest_log_selection: 0,
quest_log_abandon_mark: 0,
quest_log_abandons: Vec::new(),
+ quest_log_pushes: Vec::new(),
+ quest_confirms: 0,
item_templates: HashMap::new(),
item_sets: HashMap::new(),
item_set_asks: HashSet::new(),
diff --git a/crates/benilla-ui/src/script/quest.rs b/crates/benilla-ui/src/script/quest.rs
index d1dc1640..9ec9c300 100644
--- a/crates/benilla-ui/src/script/quest.rs
+++ b/crates/benilla-ui/src/script/quest.rs
@@ -137,7 +137,18 @@ pub enum QuestAction {
/// Reward panel Complete → `CMSG_QUESTGIVER_CHOOSE_REWARD` with the chosen choice index (0 when
/// the quest has no choice rewards).
Reward(u32),
- /// Decline / Cancel / window close → a local clear (vanilla's client-side close sends no packet).
+ /// **The quest session's end**, however it was reached: `DeclineQuest()` (the detail panel's
+ /// Decline, the progress/reward panels' Cancel, the greeting's Goodbye) and `CloseQuest()`
+ /// (the window's own OnHide — ESC, a UIPanel eviction) alike.
+ ///
+ /// **One action, because the reference has one routine.** 1733 briefly split this into
+ /// `Decline` and `Close` on the assumption that only the first was an answer on the wire; the
+ /// §5 that landed after it found both Lua verbs calling `0x501130`, along with the walk-away
+ /// watchdog and the leave-world teardown, and that routine both answers a share
+ /// (`MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`) and re-opens an NPC's list
+ /// (`CMSG_QUESTGIVER_HELLO`/`CMSG_GOSSIP_HELLO`). Which of the two it does is decided by the
+ /// GIVER, never by the button — so the app owns that fork and the engine reports one intent
+ /// (decision 1738).
Close,
}
@@ -337,13 +348,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)
}
- // AcceptQuest → Accept; DeclineQuest / CloseQuest → Close (no packet, vanilla); CompleteQuest
- // (the progress panel's Continue) → Continue (request-reward).
+ // AcceptQuest → Accept; DeclineQuest / CloseQuest → Close (the ONE session-end routine, see
+ // `QuestAction::Close`); CompleteQuest (the progress panel's Continue) → Continue.
install_action(lua, "AcceptQuest", super::QuestAction::Accept)?;
install_action(lua, "DeclineQuest", super::QuestAction::Close)?;
install_action(lua, "CloseQuest", super::QuestAction::Close)?;
install_action(lua, "CompleteQuest", super::QuestAction::Continue)?;
+ // ConfirmAcceptQuest() — the escort confirm's Yes (ref StaticPopup.lua:731-733, raised by
+ // QUEST_ACCEPT_CONFIRM). No argument and no quest id: the client answers whichever confirm it
+ // is holding, so the engine counts the calls and the app supplies the id (decision 1733).
+ g.set(
+ "ConfirmAcceptQuest",
+ lua.create_function(|lua, ()| {
+ let mut model = lua.app_data_mut::().expect("model app_data");
+ model.quest_confirms += 1;
+ Ok(())
+ })?,
+ )?;
+
// GetQuestReward(choice) — the reward panel's Complete button: queue the chosen choice index
// (Era passes the 1-based item id; the app maps it to the 0-based wire index). A quest with no
// choice rewards passes 0/nil.
@@ -571,4 +594,31 @@ mod tests {
assert_eq!(s.eval::("return GetNumQuestRewards()").unwrap(), 0);
assert!(s.eval::("return GetRewardSpell() == nil").unwrap());
}
+
+ /// `DeclineQuest` and `CloseQuest` are the SAME intent — one reference routine, `0x501130`
+ /// (decision 1738, correcting 1733's split). What differs between a decline and a close is
+ /// nothing; what differs between an NPC's quest and a shared one is everything, and that fork
+ /// belongs to the app, which knows the giver.
+ #[test]
+ fn decline_and_close_are_one_intent() {
+ let mut s = UiScript::new().unwrap();
+ s.set_quest(Some(detail()));
+ s.run("DeclineQuest()").unwrap();
+ s.run("CloseQuest()").unwrap();
+ assert_eq!(
+ s.take_quest_actions(),
+ vec![QuestAction::Close, QuestAction::Close]
+ );
+ }
+
+ /// `ConfirmAcceptQuest` counts rather than carrying a quest id — the reference's verb takes no
+ /// argument and answers whichever confirm the client holds (decision 1733).
+ #[test]
+ fn confirm_accept_quest_counts_and_drains() {
+ let mut s = UiScript::new().unwrap();
+ assert_eq!(s.take_quest_confirms(), 0);
+ s.run("ConfirmAcceptQuest() ConfirmAcceptQuest()").unwrap();
+ assert_eq!(s.take_quest_confirms(), 2);
+ assert_eq!(s.take_quest_confirms(), 0, "drained");
+ }
}
diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs
index 72aab267..cff38c79 100644
--- a/crates/benilla-ui/src/script/quest_log.rs
+++ b/crates/benilla-ui/src/script/quest_log.rs
@@ -27,8 +27,13 @@
//! from its OnUpdate without the log snapshot changing under it. [`seconds_left`] carries the
//! reference's byte-verified formula and the ways a row has no timer to show (decision 1154).
//!
-//! v1 scope (the deliberate `nil`/false stubs, each a later slice): party share
-//! (`GetQuestLogPushable`/`IsUnitOnQuest`), reward spells (`GetQuestLogRewardSpell`), and zone
+//! A fifth is the **share** (`GetQuestLogPushable`/`QuestLogPushQuest`, decision 1733): the
+//! pushable bit rides each entry (from the app's template cache) and the click resolves the
+//! selection to a quest id before it queues, so a log shuffle cannot retarget it.
+//!
+//! v1 scope (the deliberate `nil`/false stubs, each a later slice): the party quest-log tooltip
+//! (`IsUnitOnQuest` — it needs the party members' own logs, which is a different wire),
+//! reward spells (`GetQuestLogRewardSpell`), and zone
//! **headers** ([`QuestLogEntryView::is_header`] is plumbed but the app pushes a flat list —
//! headers need the QuestSort/AreaTable DBC join).
@@ -55,6 +60,13 @@ pub struct QuestLogEntryView {
pub tag: Option,
/// A zone header row (the app synthesizes these from each quest's ZoneOrSort).
pub is_header: bool,
+ /// Whether this quest may be SHARED with the party — `GetQuestLogPushable`'s answer for the
+ /// row (decision 1733). Computed app-side from the cached `SMSG_QUEST_QUERY_RESPONSE`
+ /// template's `QUEST_FLAGS_SHARABLE` (`0x8`), so a row whose template has not answered yet is
+ /// `false` and turns true when it lands — the reference's own shape, since it reads the same
+ /// cache. Never surfaced as a per-index Lua getter: the Era API asks only about the current
+ /// selection.
+ pub pushable: bool,
/// A COLLAPSED header (`GetQuestLogTitle`'s isCollapsed; meaningless on quest rows). The app
/// owns the collapse set and omits a collapsed header's quests from `entries` — the engine
/// only reports the flag and drains the toggle intents ([`super::UiScript::take_quest_log_collapses`]).
@@ -168,6 +180,19 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().quest_log_abandons)
}
+ /// Drain the share intents: the quest ids `QuestLogPushQuest()` queued, already resolved from
+ /// the selection at click time. The app sends one `CMSG_PUSHQUESTTOPARTY` per id.
+ pub fn take_quest_log_pushes(&mut self) -> Vec {
+ std::mem::take(&mut self.model_mut().quest_log_pushes)
+ }
+
+ /// Drain the escort-confirm answers: how many times `ConfirmAcceptQuest()` was called. A
+ /// count, because the verb carries no quest id (decision 1733) — the app answers the confirm
+ /// it is holding.
+ pub fn take_quest_confirms(&mut self) -> u32 {
+ std::mem::take(&mut self.model_mut().quest_confirms)
+ }
+
/// Drain the header collapse/expand intents: `(1-based entry index, collapse)` from
/// `CollapseQuestHeader`/`ExpandQuestHeader` — index `0` = ALL headers (the ref's
/// collapse-all button passes 0, QuestLogFrame.lua:557/:561). The app owns the collapse
@@ -629,10 +654,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetQuestLogRewardSpell",
lua.create_function(|_, ()| Ok(Value::Nil))?,
)?;
- g.set(
- "GetQuestLogPushable",
- lua.create_function(|_, ()| Ok(false))?,
- )?;
g.set(
"IsUnitOnQuest",
lua.create_function(|_, (_q, _unit): (Value, Value)| Ok(false))?,
@@ -643,12 +664,61 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// indices; set_quest_log prunes watches whose quest left the log. MAX_WATCHABLE_QUESTS = 5
// (ref QuestLogFrame.lua:494) — AddQuestWatch past the cap is a no-op (the Lua shows the
// QUEST_WATCH_TOO_MANY error itself, mirroring the ref's guard order).
+ /// The entry the current selection points at — what every no-argument getter on this surface
+ /// reads (`GetQuestLogPushable`, and the C side of `QuestLogPushQuest`). `None` when nothing
+ /// is selected or the selection is out of range.
+ fn selected_quest(model: &Model) -> Option<&QuestLogEntryView> {
+ (model.quest_log_selection as usize)
+ .checked_sub(1)
+ .and_then(|n| model.quest_log.entries.get(n))
+ }
+
fn watch_id_at(model: &Model, index: u32) -> Option {
(index as usize)
.checked_sub(1)
.and_then(|n| model.quest_log.entries.get(n))
.map(|e| e.quest_id)
}
+ // ── The party share (decision 1733) — the ref's `QuestFramePushQuestButton`, whose enable
+ // predicate is `GetQuestLogPushable() and GetNumPartyMembers() > 0` (QuestLogFrame.lua:299-305)
+ // and whose OnClick is `QuestLogPushQuest()` (QuestLogFrame.xml:511-513). Both speak about the
+ // CURRENT SELECTION and take no argument, exactly as the reference declares them.
+ // `GetQuestLogPushable` returns `1` or **nil**, never `false` (`0x4e12b0` tail-calls
+ // `0x6f3810`/`0x6f37f0`, decision 1738). Both are falsy to the `and` in the reference's own
+ // predicate, so the button reads the same either way — but an addon testing `== nil` would
+ // not, and this is the Era idiom the rest of this surface already speaks (`GetPartyMember`).
+ g.set(
+ "GetQuestLogPushable",
+ lua.create_function(|lua, ()| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ Ok(match selected_quest(&model).is_some_and(|e| e.pushable) {
+ true => Value::Integer(1),
+ false => Value::Nil,
+ })
+ })?,
+ )?;
+ g.set(
+ "QuestLogPushQuest",
+ lua.create_function(|lua, ()| {
+ let mut model = lua.app_data_mut::().expect("model app_data");
+ // The C verb re-tests everything the Lua predicate tests **and one thing it does not**:
+ // a party check of its own (`0x4e13a6`, decision 1738). The reference does not trust
+ // its own button — an addon or a macro can call this solo, and the send is refused
+ // here rather than reaching the server. Resolve the id HERE rather than queueing the
+ // index (see `Model::quest_log_pushes`); a HEADER row carries `quest_id` 0, so an
+ // unguarded resolve would queue a push for quest 0.
+ if model.party.members.is_empty() {
+ return Ok(());
+ }
+ let id = selected_quest(&model)
+ .filter(|e| !e.is_header && e.pushable)
+ .map(|e| e.quest_id);
+ if let Some(id) = id {
+ model.quest_log_pushes.push(id);
+ }
+ Ok(())
+ })?,
+ )?;
g.set(
"GetNumQuestWatches",
lua.create_function(|lua, ()| {
@@ -1067,4 +1137,134 @@ mod tests {
assert_eq!(s.eval::("return GetNumQuestWatches()").unwrap(), 5);
assert_eq!(s.quest_log_watched(), vec![1, 2, 3, 4, 5]);
}
+
+ /// `GetQuestLogPushable` answers about the SELECTION, and only about the selection — the Era
+ /// API has no per-index form (decision 1733). Selecting the other quest changes the answer.
+ #[test]
+ fn pushable_follows_the_selection() {
+ let mut s = UiScript::new().unwrap();
+ let mut state = two_quests();
+ state.entries[0].pushable = true; // 783 is sharable, 7 is not
+ s.set_quest_log(state);
+
+ // `1` or nil, never true/false (`0x4e12b0`, decision 1738) — an addon testing `== nil`
+ // sees the reference's own shape.
+ assert_eq!(
+ s.eval::