being summoned is a dialog, and a soulstone raises you where you fell

- being summoned becomes the dialog it should have been: the summon request was a name-table entry and nothing else, so a warlock's ritual, a meeting stone and a GM's summon all ended with the summoned player seeing nothing — now the three-field question raises CONFIRM_SUMMON and Accept sends the one guid back; the bytes settled three things a reasonable guess gets wrong: the refusal gate is dead OR ghost and lives in the handler (a ghost's wire health is 1, so the health leg alone lets one through — and refusing before the latch means an ill-timed summon can't silently overwrite a live question), the event carries no arguments because the popup re-reads all three getters every frame (the only way the summoner's name can appear after a cache miss), and there is no decline — Cancel, ESC and the clock running out are all silence plus the server's own two-minute timer; exercised end to end by a two-client live probe, the only thing that can, whose final assertion is that you land in the zone the dialog named
- self-resurrect is one private field and one bodyless opcode: PLAYER_SELF_RES_SPELL carries the stored spell and CMSG_SELF_RES spends it; HasSoulstone answers the Spell.dbc NAME of that spell, because that is where Blizzard put the button text — "Use Soulstone", "Reincarnation" and "Twisting Nether" are the spells' own names — and UseSoulstone sends on the death dialog's second button; a --self-res probe runs the whole arc on the live wire and measured the thing that matters: the field arrives BEFORE the health-zero flush, which is what makes the dialog's OnShow read safe
- the decode's fold-in corrected three claims we were resting on: HasSoulstone has a dead gate that runs FIRST, so a released ghost (wire health 1) loses the button at release rather than the dialog merely hiding; a zero field is not the empty answer — the client walks your carried inventory (gear, bags, backpack, keyring; no bank, no buyback) for an item whose on-use spell self-resurrects, labels the button with the ITEM's name and uses the item instead of sending, the two legs exclusive; and an unresolvable spell id labels the button "UNKNOWN" rather than hiding a self-res the server is holding for you

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:16:13 +07:00
co-authored by Claude Fable 5
parent 20f38e1f7e
commit 542c2ac04b
36 changed files with 1992 additions and 36 deletions
@@ -0,0 +1,93 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The "someone wants to summon you" dialog (decision 1747): a registry entry on the shared
StaticPopup engine (UiPanels.xml) plus its UIParent event arm, written against the
patch-chain FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1336-1357 (the
entry) and UIParent.lua l.126 / l.551-552 (the registration and the show arm). The string is
GlobalStrings.lua l.852, verbatim.
It belongs to no window, like the duel and binder confirms beside it: a warlock's Ritual of
Summoning, a meeting stone and a GM's `.summon` all arrive as the same SMSG_SUMMON_REQUEST
with nothing on screen.
**The event carries NO arguments** — unlike every other confirm here. The engine keeps the
request in four globals and the dialog reads them back through GetSummonConfirmSummoner(),
GetSummonConfirmAreaName() and GetSummonConfirmTimeLeft() (`crate::ui_summon`'s header
carries the byte pin). That is also why this entry's text starts BLANK: it is one of the
engine's countdown dialogs, so StaticPopup_Show writes " " and the OnUpdate tick composes
the real line every frame from those getters plus the remaining time.
**Accepting is the only packet in the flow.** There is no decline opcode in 1.12 and no
CancelSummon binding, so Cancel and ESC send nothing and the offer simply expires on the
server's own two-minute timer — which is also what the countdown running out means. That is
why this entry, like CONFIRM_BINDER, has no OnCancel.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
-- %s summoner · %s area · %d count · %s the count's unit word (StaticPopupTimeUnit).
local CONFIRM_SUMMON =
"%s wants to summon you to %s. The spell will be cancelled in %d %s." -- l.852
local ACCEPT = "Accept" -- l.11
local CANCEL = "Cancel" -- l.465
-- ── The confirm — answering to StaticPopup.lua l.1336-1357 ─────────────────────────────
-- OnShow seeds `timeleft` from the engine, which is what arms the popup engine's countdown
-- branch; from there the dialog ticks it down locally and hides itself at zero, sending
-- nothing. GetSummonConfirmTimeLeft() returns whole seconds, already truncated by the
-- binding (`0x48b660`), so this is the reference's own resolution and not a rounding of ours.
--
-- The OnUpdate is a COMBAT LOCK, not a teardown: you may not accept a summon while
-- fighting, so Button1 disables and re-enables while the dialog stays up. Every other
-- confirm in this folder polls to decide whether to *hide*; this one polls to decide
-- whether the answer is available. Cancel stays live throughout.
local function lockAcceptWhileInCombat(_, dialog)
local button = getglobal(dialog:GetName() .. "Button1")
if UnitAffectingCombat("player") then
button:Disable()
else
button:Enable()
end
end
StaticPopupDialogs["CONFIRM_SUMMON"] = {
text = CONFIRM_SUMMON,
button1 = ACCEPT,
button2 = CANCEL,
OnShow = function()
this.timeleft = GetSummonConfirmTimeLeft()
end,
OnAccept = function()
ConfirmSummon()
end,
OnUpdate = lockAcceptWhileInCombat,
timeout = 0,
-- A summon is worth interrupting a fly-by for: without this the whole question would be
-- cancelled unseen by StaticPopup_Show's cinematic gate, and the offer would expire
-- while the player watched a cutscene.
interruptCinematic = 1,
-- v1-inert in our engine (UiPanels.xml's header lists it) — transcribed because it is
-- part of the entry an addon may read.
notClosableByLogout = 1,
hideOnEscape = 1,
}
-- ── The UIParent event arm (UIParent.lua l.551-552) ────────────────────────────────────
-- No arguments: the getters above are the payload.
function BenillaConfirmSummonDriver_OnEvent()
if event == "CONFIRM_SUMMON" then
StaticPopup_Show("CONFIRM_SUMMON")
end
end
]]></Script>
<Frame name="BenillaConfirmSummonDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("CONFIRM_SUMMON")
</OnLoad>
<OnEvent>BenillaConfirmSummonDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
+20 -7
View File
@@ -1,7 +1,8 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The death arc's UI (decision 0308 slices 1/3): our death dialogs, registered on the shared
StaticPopup engine (UiPanels.xml), plus the event driver that raises and clears them.
The death arc's UI (decision 0308 slices 1/3, plus 1746's self-resurrect): our death
dialogs, registered on the shared StaticPopup engine (UiPanels.xml), plus the event driver
that raises and clears them.
The patch-chain FrameXML (mpqx extraction, decision 0246) is the SPECIFICATION for what
each dialog says, offers and answers — StaticPopup.lua l.375-505 (DEATH + RESURRECT×3),
@@ -17,14 +18,20 @@
RESURRECT_REQUEST + CONFIRM_XP_LOSS fire from `death.rs`'s state machine;
CORPSE_IN_RANGE/OUT_OF_RANGE/IN_INSTANCE join with the slice-2 corpse-run range gate
(registered now — they simply don't fire yet). Deferred with 0308 §8: the SKINNED/
SKINNED_REPOP pair (battleground insignia) and UseSoulstone (HasSoulstone() is nil).
SKINNED_REPOP pair (battleground insignia).
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim; %% escapes as shipped) ─────────────────────────
local DEATH_RELEASE_TIMER = "%d %s until release"
local DEATH_RELEASE_NOTIMER = "You have died. Release to the nearest graveyard?"
local DEATH_RELEASE = "Release Spirit"
local USE_SOULSTONE = "Reincarnate" -- the shaman default; OnShow overrides with HasSoulstone()'s text
-- Vestigial on this build, and kept anyway because it is what the reference registers:
-- HasSoulstone() returns a Spell.dbc NAME whenever it returns anything, and OnShow stamps
-- that over button2 — so the only case this default could render is one that cannot
-- happen (DisplayButton2 shows the button on exactly the same call). It is the pre-1.6
-- wording, from before "Self-resurrection spells show their name on the button" (decision
-- 1746); GlobalStrings still ships it with the comment "Use your soulstone if you have one".
local USE_SOULSTONE = "Reincarnate"
local RECOVER_CORPSE_TEXT = "Resurrect now?"
local RECOVER_CORPSE_TIMER = "%d %s until resurrection"
local RECOVER_CORPSE_INSTANCE_TEXT = "You must enter the instance to recover your corpse"
@@ -63,8 +70,9 @@
-- its per-tick DEATH text; −1 means this map has no forced release (an instance), so the
-- static wording goes in and nothing counts down. `timeout = 0` because the countdown is
-- the SERVER's to enforce — it force-releases at 6:00 (0308 §1) — and there is deliberately
-- no hideOnEscape: you cannot dismiss dying. Button2 appears only with a soulstone on you,
-- which is never in v1 (HasSoulstone() is nil), so a cancel falls through to the release.
-- no hideOnEscape: you cannot dismiss dying. Button2 appears only when the server owes us
-- a self-resurrect (PLAYER_SELF_RES_SPELL non-zero); without one a cancel falls through
-- to the release.
register_corpse_dialog("DEATH", {
text = DEATH_RELEASE_TIMER,
button1 = DEATH_RELEASE,
@@ -78,7 +86,8 @@
if this.timeleft == -1 then
getglobal(name .. "Text"):SetText(DEATH_RELEASE_NOTIMER)
end
-- The soulstone names its own button ("Reincarnate", "Use Soulstone", …).
-- The self-res names its own button, out of Spell.dbc: "Use Soulstone" (3026,
-- 20758-20761), "Reincarnation" (21169), "Twisting Nether" (23700).
local soulstone = HasSoulstone()
if soulstone then
getglobal(name .. "Button2"):SetText(soulstone)
@@ -93,6 +102,10 @@
if reason ~= "clicked" then
return
end
-- Button2 is the self-resurrect (decision 1746): CMSG_SELF_RES, and the server
-- casts whatever PLAYER_SELF_RES_SPELL holds. Re-asked rather than remembered
-- from OnShow, exactly as the reference does — the field is the server's and can
-- have been spent since the dialog opened.
if HasSoulstone() then
UseSoulstone()
else
+19 -6
View File
@@ -1322,8 +1322,9 @@
-- Ref l.1712-1808: the timeout countdown (expiry → OnCancel "timeout" + hide), the
-- per-tick countdown text for the timer dialogs, the StartDelay gate (button1 disabled,
-- delayText counts down, then the real text swaps in and the button enables), then the
-- entry's own OnUpdate. The INSTANCE_BOOT/CONFIRM_SUMMON special text formats join with
-- their dialogs; their which-list slots stay so the gate is the ref's.
-- entry's own OnUpdate. CONFIRM_SUMMON's special text format landed with its dialog
-- (decision 1747); INSTANCE_BOOT's joins when that dialog does, and its which-list slot
-- stays meanwhile so the gate is the ref's.
function StaticPopup_OnUpdate(dialog, elapsed)
local info = StaticPopupDialogs[dialog.which]
if not info then
@@ -1347,11 +1348,23 @@
or (which == "CONFIRM_SUMMON") or (which == "AREA_SPIRIT_HEAL") then
local text = getglobal(dialog:GetName() .. "Text")
timeleft = ceil(timeleft)
if timeleft < 60 then
text:SetText(format(info.text, timeleft, StaticPopupTimeUnit(timeleft, false)))
local n, unit = timeleft, StaticPopupTimeUnit(timeleft, false)
if timeleft >= 60 then
n = ceil(timeleft / 60)
unit = StaticPopupTimeUnit(n, true)
end
-- The ref's own if/elseif over `which` (l.1746-1758), which exists because two
-- of these dialogs take MORE than the count and its unit word. CONFIRM_SUMMON
-- prefixes the summoner and the destination, both read live from the engine
-- every tick — which is what lets a name still resolving through the name cache
-- paint blank once and then fill itself in (decision 1747). INSTANCE_BOOT's arm
-- (GetBindLocation() first) joins when that dialog does; until then it falls
-- through here, which is wrong for it and harmless, since it cannot be shown.
if which == "CONFIRM_SUMMON" then
text:SetText(format(info.text, GetSummonConfirmSummoner(),
GetSummonConfirmAreaName(), n, unit))
else
local m = ceil(timeleft / 60)
text:SetText(format(info.text, m, StaticPopupTimeUnit(m, true)))
text:SetText(format(info.text, n, unit))
end
end
end
+6
View File
@@ -114,6 +114,12 @@ EnchantConfirm.xml
# Not GossipFrame's, and the wire is why — the server closes the gossip menu BEFORE it asks, so
# this dialog is free-standing by the time it shows. Needs only UiPanels.xml, far above.
BinderConfirm.xml
# The summon confirm + its event driver (decision 1747): the same StaticPopup registry once more.
# Free-standing like the two around it, and for a stronger reason — a warlock's ritual, a meeting
# stone and a GM `.summon` all raise it with nothing whatever on screen. The only entry here whose
# event carries no arguments: the dialog reads the summoner, the destination and the clock back out
# of the engine every tick, so it needs UiPanels.xml's countdown branch as well as its registry.
ConfirmSummon.xml
# The class trainer's respec confirm + its event driver (decision 1580): the binder confirm's twin
# one line up, free-standing for the identical reason — the server closes the gossip menu before it
# asks. The only entry in the registry that raises a money frame, so it needs MoneyFrame.xml (far
+343
View File
@@ -15,6 +15,7 @@ use benilla_assets::coords::wow_to_bevy;
use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
/// Where our corpse is — the `MSG_CORPSE_QUERY` answer (decision 0308 §5). Raw WoW coords.
@@ -174,6 +175,8 @@ fn feed_death(
transforms: Query<&Transform>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
status: Res<crate::net::NetStatus>,
spells: Option<Res<Spells>>,
mut items: ResMut<crate::items::Items>,
) {
// **Only a LIVE session's descriptor is a snapshot** (decision 1732). A reconnect-able
// disconnect keeps the self avatar as the local puppet (0065) — descriptor and all — so
@@ -225,6 +228,11 @@ fn feed_death(
resurrect_has_timer: death_net.resurrect.as_ref().is_some_and(|o| o.has_timer),
spirit_healer_in_range,
sickness_duration: sickness_duration(store.0.unit_level().unwrap_or(0)),
// `HasSoulstone()` — see [`resolve_self_res`] for the three gates and their order. Not a
// per-frame inventory walk in general: the dead gate is first, so while alive this is one
// health read, and while dead-unreleased the walk only runs on a zero field.
self_res_label: resolve_self_res(&store.0, &mut items, spells.as_deref(), &net)
.map(|r| r.label().to_owned()),
});
// ── The WORLD edges (the body's own state machine): the release-window anchor and the
@@ -354,6 +362,102 @@ fn feed_death(
}
}
/// `SPELL_EFFECT_SELF_RESURRECT` — `Spell.dbc`'s effect-slot value the client's item leg scans for
/// (`0x5ed650`, `cmp … 0x5e`). Exactly eleven 1.12 spells carry it.
const SPELL_EFFECT_SELF_RESURRECT: u32 = 94;
/// What a `UseSoulstone()` would spend right now — the client's own two-legged fork
/// (`Script::UseSoulstone 0x48ad70`, wow-re death-ui.md §10.4), resolved app-side because neither
/// leg's inputs (the spell catalog, the item cache) are bound in the script VM.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum SelfRes {
/// `PLAYER_SELF_RES_SPELL` is non-zero — send `CMSG_SELF_RES` and let the server cast it.
Spell {
#[allow(dead_code)] // the wire needs no id: the server reads its own field
spell: u32,
label: String,
},
/// The field is zero, but a carried item's ON-USE spell self-resurrects — the client **uses
/// the item** instead, and labels the button with the ITEM's name.
Item {
bag_index: u8,
slot: u8,
guid: u64,
entry: u32,
label: String,
},
}
impl SelfRes {
/// The button text — what `HasSoulstone()` returns.
fn label(&self) -> &str {
match self {
Self::Spell { label, .. } | Self::Item { label, .. } => label,
}
}
}
/// **`HasSoulstone()`'s whole answer**, and `UseSoulstone()`'s routing — one resolver, because the
/// binary is one predicate the two bindings share (wow-re death-ui.md §10.1/§10.4).
///
/// Three gates in the client's own order, and the order is the performance story as much as the
/// fidelity one:
///
/// 1. **Dead.** `HasSoulstone` reads `UNIT_FIELD_HEALTH` (signed) and answers nil while it is
/// positive — so it speaks only inside the pre-release dead window, which is exactly where the
/// DEATH dialog lives. A released ghost has health **1** (vmangos `BuildPlayerRepop`), so it
/// stops answering the moment you release, and the walk below never runs while alive.
/// 2. **The field.** Non-zero → the spell leg, named through `Spell.dbc`. An id the catalog cannot
/// resolve is **`"UNKNOWN"`**, not nil — the client pushes that literal rather than hiding the
/// button, and hiding it would silently strip a self-res the server is holding for us.
/// 3. **The carried item.** Only on a zero field: the reference's own inventory walker at its
/// default section mask (gear + bags + backpack + keyring, no bank, no buyback — which is
/// [`InventoryScope::DEFAULT`], the same `0x47` the walker rewrites to) for an item with an
/// ON-USE (`trigger == 0`) spell carrying [`SPELL_EFFECT_SELF_RESURRECT`] in any effect slot.
/// The label is the **item's** name.
///
/// A template still in flight can't be judged and reads as "not a self-res"; the ask-once query
/// the lookup fires answers within a frame or two and the next resolve sees it.
fn resolve_self_res(
store: &benilla_protocol::ObjectFields,
items: &mut crate::items::Items,
spells: Option<&Spells>,
commands: &NetCommands,
) -> Option<SelfRes> {
if !store.unit_is_dead() {
return None;
}
if let Some(spell) = store.player_self_res_spell() {
let label = spells
.and_then(|s| s.catalog.get(spell))
.map_or_else(|| "UNKNOWN".to_owned(), |d| d.name.clone());
return Some(SelfRes::Spell { spell, label });
}
// Collected first, then judged: the template lookup needs `items` mutably (it is ask-once,
// so a miss fires the query) while the walk holds it immutably — `has_key`'s own idiom.
let slots =
crate::ui_items::collect_inventory(store, items, crate::ui_items::InventoryScope::DEFAULT);
slots.into_iter().find_map(|(bag_index, slot, guid)| {
let entry = items.object(guid)?.object_entry()?;
let t = items.template(entry, guid, commands)?;
let self_res = t.spells.iter().any(|sp| {
sp.trigger == 0
&& spells.is_some_and(|c| {
c.catalog
.get(sp.spell_id)
.is_some_and(|d| d.effects.contains(&SPELL_EFFECT_SELF_RESURRECT))
})
});
self_res.then(|| SelfRes::Item {
bag_index,
slot,
guid,
entry,
label: t.name.clone(),
})
})
}
/// The sickness-duration string a spirit-healer res would apply at `level` — the verified server
/// table (vmangos `Player::ResurrectPlayer` + `Death.SicknessLevel` 11, 0308 §6): nil below 11,
/// `(level − 10)` minutes through 19, the aura's full 10 minutes from 20.
@@ -408,6 +512,8 @@ fn drain_death(
script: Option<NonSendMut<UiScript>>,
mut death_net: ResMut<DeathNet>,
net: Res<NetCommands>,
targeting: crate::ui_action::cast_target::CastTargeting,
mut ladder: crate::ui_action::CastLadder,
) {
let Some(mut script) = script else {
return;
@@ -436,6 +542,58 @@ fn drain_death(
Some(npc) => net.0.send(ClientCommand::SpiritHealerActivate { npc }),
None => Ok(()),
},
// The self-resurrect button — the client's own two-legged fork, **re-resolved
// here at click time** rather than remembered from the frame the dialog opened. That
// is the reference's posture too: its `OnCancel` calls `HasSoulstone()` again before
// branching, because the field belongs to the server and can have been spent since.
//
// Nothing is taken or cleared on our side on either leg: the server owns
// `PLAYER_SELF_RES_SPELL` and zeroes it as it casts, so the button's disappearance is
// a descriptor delta like every other death-arc confirmation.
DeathAction::UseSoulstone => {
let store = targeting.self_store.single().ok().map(|s| s.0.clone());
match store.as_ref().and_then(|store| {
resolve_self_res(
store,
&mut ladder.items,
ladder.spells.as_deref(),
&ladder.commands,
)
}) {
Some(SelfRes::Spell { .. }) => net.0.send(ClientCommand::SelfRes),
// The item leg is an ordinary item use — the same `CGItem_C::Use 0x5d8d00`
// every bag click and action button ends at, which is why it goes through the
// one send rather than growing a second path (decision 0914).
Some(SelfRes::Item {
bag_index,
slot,
guid,
entry,
..
}) => {
let t = ladder
.items
.template(entry, guid, &ladder.commands)
.cloned();
let it = crate::ui_items::ItemUse {
guid: Some(guid),
start_quest: t.as_ref().map_or(0, |t| t.start_quest),
bag_index,
slot,
entry,
spell_index: t.as_ref().and_then(|t| t.use_spell_index()).unwrap_or(0),
use_spell: t.as_ref().and_then(|t| t.use_spell).map(|u| u.spell_id),
on_object: None,
is_charter: false,
};
crate::ui_items::send_item_use(it, &targeting.context(), &mut ladder);
Ok(())
}
// Nothing to spend — the reference's silent return (it pushes nothing and
// sends nothing on this path either).
None => Ok(()),
}
}
};
}
}
@@ -543,3 +701,188 @@ mod tests {
);
}
}
#[cfg(test)]
mod self_res_tests {
use std::collections::HashMap;
use benilla_formats::{SpellCatalog, SpellDisplay};
use benilla_protocol::messages::{ItemInfo, ItemSpellEntry};
use benilla_protocol::ObjectFields;
use super::{resolve_self_res, SelfRes, SPELL_EFFECT_SELF_RESURRECT};
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::Spells;
/// Reincarnation's effect spell — what the server writes into the field, named in `Spell.dbc`
/// as the button text (decision 1746).
const REINCARNATION: u32 = 21169;
/// `PLAYER_SELF_RES_SPELL`.
const F_SELF_RES: u16 = 1224;
/// `UNIT_FIELD_HEALTH` / `UNIT_FIELD_MAXHEALTH`. Both, because benilla's `unit_is_dead()`
/// guards on a non-zero max — an all-empty store must not read as a corpse (its own doc). The
/// binary's gate is the bare signed `health <= 0`; for the self player the two never differ.
const F_HEALTH: u16 = 22;
const F_MAXHEALTH: u16 = 28;
/// `PLAYER_FIELD_PACK_SLOT_1` — the backpack's 16 slots, two dwords per guid.
const F_PACK_1: u16 = 532;
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
fn catalog(pairs: impl IntoIterator<Item = (u32, SpellDisplay)>) -> Spells {
Spells {
catalog: SpellCatalog::from_displays(pairs.into_iter().collect::<HashMap<_, _>>()),
..Spells::empty_for_tests()
}
}
fn named(name: &str, effects: [u32; 3]) -> SpellDisplay {
SpellDisplay {
name: name.to_string(),
effects,
..Default::default()
}
}
/// **The dead gate comes first** (wow-re death-ui.md §10.1): `HasSoulstone` reads
/// `UNIT_FIELD_HEALTH` and answers nil while it is positive. A living player holding a
/// standing self-res spell gets nothing — and neither does a released **ghost**, whose health
/// the server sets to exactly 1 (`BuildPlayerRepop`). That is what confines the answer to the
/// pre-release window the DEATH dialog lives in.
#[test]
fn the_dead_gate_precedes_the_field() {
let (net, _rx) = commands();
let mut items = Items::default();
let spells = catalog([(REINCARNATION, named("Reincarnation", [94, 0, 0]))]);
let alive = ObjectFields::from_pairs(&[
(F_MAXHEALTH, 4000),
(F_HEALTH, 4000),
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&alive, &mut items, Some(&spells), &net),
None,
"alive with a self-res owed: nil"
);
// A ghost's health is 1, not 0 — the same positive-health path, which is why releasing
// takes the button away rather than the dialog merely hiding it.
let ghost = ObjectFields::from_pairs(&[
(F_MAXHEALTH, 4000),
(F_HEALTH, 1),
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&ghost, &mut items, Some(&spells), &net),
None,
"a released ghost still holds the field, and still answers nil"
);
let dead = ObjectFields::from_pairs(&[
(F_MAXHEALTH, 4000),
(F_HEALTH, 0),
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&dead, &mut items, Some(&spells), &net),
Some(SelfRes::Spell {
spell: REINCARNATION,
label: "Reincarnation".into(),
})
);
}
/// An id the catalog cannot resolve is **"UNKNOWN"**, not nil (§10.1's fourth exit). Hiding
/// the button instead would silently strip a self-res the server is holding for us.
#[test]
fn an_unresolvable_spell_id_reads_unknown_not_nil() {
let (net, _rx) = commands();
let mut items = Items::default();
let dead =
ObjectFields::from_pairs(&[(F_MAXHEALTH, 4000), (F_HEALTH, 0), (F_SELF_RES, 999_999)]);
for spells in [None, Some(catalog([]))] {
assert_eq!(
resolve_self_res(&dead, &mut items, spells.as_ref(), &net),
Some(SelfRes::Spell {
spell: 999_999,
label: "UNKNOWN".into(),
}),
"no catalog and an empty catalog are the same miss"
);
}
}
/// **A zero field is not the nil path** (§10.3): the client falls through to the carried
/// inventory and looks for an item whose ON-USE (`trigger == 0`) spell carries
/// `SPELL_EFFECT_SELF_RESURRECT`, labelling the button with the ITEM's name. Three items
/// stand in the backpack — one whose self-res spell is on an EQUIP trigger (must not count),
/// one whose on-use spell is something else (must not count), and the real one.
#[test]
fn a_zero_field_falls_through_to_a_carried_item() {
let (net, _rx) = commands();
let mut items = Items::default();
let item = |name: &str, spells: Vec<ItemSpellEntry>| ItemInfo {
spells,
..crate::items::test_template(name)
};
let block = |spell_id: u32, trigger: u32| ItemSpellEntry {
index: 0,
spell_id,
trigger,
charges: 0,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
};
// 3026 "Use Soulstone" is the real effect-94 spell; 439 "Healing Potion" is not.
items.insert_template(10, Some(item("Healthstone", vec![block(439, 0)])));
items.insert_template(11, Some(item("Worn Trinket", vec![block(3026, 1)])));
items.insert_template(
12,
Some(item("Ankh of Reincarnation", vec![block(3026, 0)])),
);
for (guid, entry) in [(0xA0_u64, 10_u32), (0xA1, 11), (0xA2, 12)] {
items.insert_object(guid, ObjectFields::from_pairs(&[(3, entry)]));
}
let spells = catalog([
(
3026,
named("Use Soulstone", [SPELL_EFFECT_SELF_RESURRECT, 0, 0]),
),
(439, named("Healing Potion", [6, 0, 0])),
]);
// Backpack slots 23/24/25 hold the three, in that order.
let mut pairs = vec![(F_MAXHEALTH, 4000), (F_HEALTH, 0)];
for (i, guid) in [0xA0_u64, 0xA1, 0xA2].iter().enumerate() {
let f = F_PACK_1 + 2 * i as u16;
pairs.push((f, (*guid & 0xffff_ffff) as u32));
pairs.push((f + 1, (*guid >> 32) as u32));
}
let dead = ObjectFields::from_pairs(&pairs);
match resolve_self_res(&dead, &mut items, Some(&spells), &net) {
Some(SelfRes::Item { entry, label, .. }) => {
assert_eq!(
entry, 12,
"the EQUIP-trigger copy and the potion are skipped"
);
assert_eq!(label, "Ankh of Reincarnation", "the ITEM names the button");
}
other => panic!("expected the item leg, got {other:?}"),
}
// Take it away and the answer is nil again — not "UNKNOWN", which is the spell leg's.
let bare = ObjectFields::from_pairs(&[(F_MAXHEALTH, 4000), (F_HEALTH, 0)]);
assert_eq!(
resolve_self_res(&bare, &mut items, Some(&spells), &net),
None
);
}
}
+6
View File
@@ -154,6 +154,7 @@ mod ui_shapeshift;
mod ui_social;
mod ui_spellbook;
mod ui_stable;
mod ui_summon;
mod ui_talent;
mod ui_talent_wipe;
mod ui_taxi;
@@ -230,6 +231,7 @@ use ui_shapeshift::UiShapeshiftPlugin;
use ui_social::UiSocialPlugin;
use ui_spellbook::UiSpellbookPlugin;
use ui_stable::UiStablePlugin;
use ui_summon::UiSummonPlugin;
use ui_talent::UiTalentPlugin;
use ui_talent_wipe::UiTalentWipePlugin;
use ui_taxi::UiTaxiPlugin;
@@ -615,6 +617,10 @@ pub fn run(build: BuildId) -> AppExit {
// CONFIRM_BINDER dialog it raises, and the CMSG_BINDER_ACTIVATE its Accept sends — the only
// packet in the flow that actually binds anything.
.add_plugins(UiBinderPlugin)
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
// server-asked question whose only wire answer is yes — and here for that reason.
.add_plugins(UiSummonPlugin)
// The GM trouble-ticket flow (decision 1673): the Help window's five sends, the UPDATE_TICKET
// answer ticket behind its 10-minute poll, and the GMTicketCategory.dbc list its "page a GM"
// rows are built from. Beside the binder because it is the same feed/drain shape, and after it
+11
View File
@@ -1214,6 +1214,13 @@ pub(crate) enum ClientCommand {
BinderActivate {
binder: u64,
},
/// Accept a summon (`CMSG_SUMMON_RESPONSE`, decision 1747): the `CONFIRM_SUMMON` dialog's
/// Accept, carrying the guid the question arrived with. The **only** packet in the summon
/// flow — there is no decline opcode, so a refused summon expires on the server's own timer
/// and this client says nothing.
SummonResponse {
summoner: u64,
},
/// Unlearn every talent (`MSG_TALENT_WIPE_CONFIRM` outbound, decision 1580): the
/// `CONFIRM_TALENT_WIPE` dialog's Accept, carrying the guid the trainer's question asked with.
/// This is the ONLY packet in the flow that resets anything — selecting the gossip line just
@@ -1773,6 +1780,10 @@ pub(crate) enum ClientCommand {
ReclaimCorpse {
corpse: u64,
},
/// Self-resurrect (`CMSG_SELF_RES`, empty body — the DEATH popup's soulstone/Reincarnation
/// button, decision 1746). The server casts whatever `PLAYER_SELF_RES_SPELL` holds; success
/// returns as ordinary descriptor deltas, no answer packet.
SelfRes,
/// Take the spirit healer's resurrection (`CMSG_SPIRIT_HEALER_ACTIVATE` — the XP_LOSS
/// confirm's final Accept): 50% res, 25% durability, sickness at level ≥ 11.
SpiritHealerActivate {
+31
View File
@@ -266,6 +266,10 @@ pub(super) fn apply_net_updates(
// survive walking away from the registrar.
ResMut<crate::ui_petition::GuildRegistrarState>,
ResMut<crate::ui_petition::PetitionState>,
// The pending summon question (decision 1747) — `SMSG_SUMMON_REQUEST` parks the
// summoner's guid, zone and expiry here and `crate::ui_summon` turns it into the
// CONFIRM_SUMMON dialog, whose Accept is the only packet in the flow.
ResMut<crate::ui_summon::SummonState>,
),
),
),
@@ -450,6 +454,7 @@ pub(super) fn apply_net_updates(
mut gm_ticket,
mut registrar,
mut petition,
mut summon,
),
),
) = caches;
@@ -806,6 +811,32 @@ pub(super) fn apply_net_updates(
});
}
SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc),
// Someone is asking to pull us to them (decision 1747). The reference gates this in
// the HANDLER, not the dialog: a dead or ghost player's request is dropped before the
// latch, so it cannot disturb a live question either (`0x5e6194`). The predicate is
// `0x605f30` — **health ≤ 0 OR (is-player AND `PLAYER_FLAGS` ghost bit)**, which is
// both of these accessors and not the one `unit_is_dead` alone would give (a ghost's
// wire health is 1). A self object we have not streamed yet reads as alive: the
// reference's own default (`0x5e6189` sends a NULL object through to the latch).
SessionEvent::SummonRequest {
summoner,
zone,
delay_ms,
} => {
let dead_or_ghost = self_guid
.0
.and_then(|g| index.0.get(&g))
.and_then(|e| stores.get(*e).ok())
.is_some_and(|s| s.0.unit_is_dead() || s.0.player_is_ghost());
crate::ui_summon::apply::request(
summoner,
zone,
delay_ms,
dead_or_ghost,
aura.1.elapsed_secs_f64(),
&mut summon,
);
}
// The GM ticket answers (decision 1673). The GETTICKET arm takes EVERY answer,
// including `None` ("you have no ticket") and including an unsolicited one pushed by a
// GM's `.ticket view`/`escalate`/`complete` — they are indistinguishable on the wire
+2
View File
@@ -840,6 +840,7 @@ fn writer_loop(
ClientCommand::GmTicketDelete => w.gm_ticket_delete(),
ClientCommand::GmTicketSystemStatus => w.gm_ticket_system_status(),
ClientCommand::BinderActivate { binder } => w.binder_activate(binder),
ClientCommand::SummonResponse { summoner } => w.summon_response(summoner),
ClientCommand::TalentWipeConfirm { trainer } => w.talent_wipe_confirm(trainer),
ClientCommand::BankerActivate { guid } => w.banker_activate(guid),
ClientCommand::BuyBankSlot { guid } => w.buy_bank_slot(guid),
@@ -1059,6 +1060,7 @@ fn writer_loop(
ClientCommand::RepopRequest => w.repop_request(),
ClientCommand::CorpseQuery => w.corpse_query(),
ClientCommand::ReclaimCorpse { corpse } => w.reclaim_corpse(corpse),
ClientCommand::SelfRes => w.self_res(),
ClientCommand::SpiritHealerActivate { npc } => w.spirit_healer_activate(npc),
ClientCommand::ResurrectResponse { caster, accept } => {
w.resurrect_response(caster, accept)
+21
View File
@@ -519,6 +519,27 @@ pub(crate) fn find_item(
})
}
/// Every occupied slot `scope` reaches, **in the walker's own order** — [`walk_inventory`]'s visit
/// sequence collected rather than searched.
///
/// It exists because some predicates need the item's TEMPLATE, and the template lookup wants
/// [`Items`] mutably (it is ask-once: a miss fires the query) while the walk holds it immutably.
/// `has_key` solved that by hand-listing the slots it wanted; this keeps the one real walker and
/// its load-bearing order (decisions 0666/1158) and just defers the judging by one step.
pub(crate) fn collect_inventory(
store: &ObjectFields,
items: &Items,
scope: InventoryScope,
) -> Vec<(u8, u8, u64)> {
let mut out = Vec::new();
// `None` throughout — the walk is never stopped, so every slot in scope lands in `out`.
walk_inventory(store, items, scope, |bag, slot, guid| {
out.push((bag, slot, guid));
None::<()>
});
out
}
/// The reference's **`HasKey()`** (`0x48ae90`) — "does this player own a key at all?", the one
/// gate that decides whether the keyring exists in the UI (decision 0765). Byte-read: it fetches
/// the active player, then runs the same inventory walker `find_item` transcribes
@@ -1,7 +1,7 @@
//! The death-arc UI (decision 0308, DeathFrame.xml): the DEATH release popup's show/countdown/
//! release flow, the resurrect-offer popup pick, and the spirit-healer XP_LOSS two-step — the Lua
//! wiring between the engine's death events and the StaticPopup engine, driven exactly as
//! `death.rs`'s feed does it (set_death → fire_event).
//! The death-arc UI (decisions 0308/1746, DeathFrame.xml): the DEATH release popup's
//! show/countdown/release flow, its self-resurrect button, the resurrect-offer popup pick, and
//! the spirit-healer XP_LOSS two-step — the Lua wiring between the engine's death events and the
//! StaticPopup engine, driven exactly as `death.rs`'s feed does it (set_death → fire_event).
use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
@@ -86,6 +86,86 @@ fn death_popup_counts_down_and_release_queues_repop() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The self-resurrect leg (decision 1746): with `PLAYER_SELF_RES_SPELL` naming a spell, the DEATH
/// popup grows its second button, the button wears the SPELL's name rather than the registry's
/// "Reincarnate" default, and clicking it queues `UseSoulstone` — while button1 still releases.
#[test]
fn death_popup_offers_the_self_resurrect_and_spends_it() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Use Soulstone".into()),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"a self-res owed ⇒ DisplayButton2 shows the second button"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Use Soulstone",
"OnShow stamps the Spell.dbc name over the registry's vestigial \"Reincarnate\""
);
// Button1 is still the release, and it is NOT the self-res.
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::Repop]);
// Button2 spends it. The dialog is raised again first, because clicking either button hides it.
s.fire_event("PLAYER_DEAD", vec![]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the self-res hides the dialog; the resurrection itself lands as descriptor deltas"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A shaman's Reincarnation is the same path with a different name — and the button is decided by
/// `HasSoulstone()` at SHOW time, so a self-res that arrives after the popup is up does not
/// retro-fit a button onto it (the reference's own StaticPopup engine re-evaluates neither
/// `DisplayButton2` nor `OnShow` per tick).
#[test]
fn death_popup_button2_is_decided_at_show_time() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(!s
.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap());
// The field lands late: the standing dialog does not grow a button…
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Reincarnation".into()),
..Default::default()
});
s.tick(0.05);
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no per-tick re-evaluation — the button set is fixed at StaticPopup_Show"
);
// …but the OnCancel arm re-asks, so a click on a button that is not there cannot mis-route,
// and the next raise picks it up.
s.run("StaticPopup_Hide(\"DEATH\")").unwrap();
s.fire_event("PLAYER_DEAD", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Reincarnation"
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The no-timer variant: GetReleaseTimeRemaining() == −1 (an instanceable map) swaps in the
/// DEATH_RELEASE_NOTIMER text and never counts down.
#[test]
+3
View File
@@ -914,6 +914,9 @@ mod static_popup_tests;
#[cfg(test)]
mod binder_tests;
#[cfg(test)]
mod summon_tests;
#[cfg(test)]
mod talent_wipe_tests;
@@ -0,0 +1,278 @@
//! The summon confirm (decision 1747, ConfirmSummon.xml): the dialog `ui_summon`'s feed raises,
//! the countdown line the popup engine composes from the four engine globals, the combat lock on
//! its Accept, and the one call that becomes `CMSG_SUMMON_RESPONSE`.
//!
//! This dialog is the only one in the folder whose event carries **no arguments** — everything on
//! screen is read back out of the engine every tick — so these tests drive the getters' snapshot
//! (`set_summon_confirm`) rather than an event payload, which is exactly how the app drives it.
use benilla_ui::script::{SummonConfirmUiState, UiScript, UnitState};
/// A `"player"` snapshot that exists and is (or is not) fighting — the one field this dialog's
/// OnUpdate reads. `UnitAffectingCombat` answers on `exists && in_combat`, so the flag alone would
/// not be enough.
fn player(in_combat: bool) -> UnitState {
UnitState {
exists: true,
in_combat,
..UnitState::default()
}
}
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the binder 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
}
/// The app's own pre-state: a live offer from Twomage, out of Stormwind City, with the server's
/// full two-minute window still on the clock.
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, "ConfirmSummon.xml");
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Stormwind City".into(),
time_left_ms: 120_000,
});
s
}
/// The whole arc: `CONFIRM_SUMMON` (no args) raises the dialog, the popup engine's countdown tick
/// composes the line from the three getters, and Accept queues the one `ConfirmSummon()` that
/// becomes `CMSG_SUMMON_RESPONSE`.
///
/// The **text starts blank** and is filled by the tick — that is the engine's countdown contract
/// (`StaticPopup_Show` writes `" "` for this `which`), and the reason this dialog needs the
/// per-tick branch at all rather than `StaticPopup_Show`'s arguments.
#[test]
fn the_confirm_names_the_summoner_and_accept_queues_the_response() {
let mut s = setup();
s.fire_event("CONFIRM_SUMMON", Vec::new());
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"CONFIRM_SUMMON shows the dialog"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
" ",
"a countdown dialog opens blank; its OnUpdate writes the line"
);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Stormwind City. The spell will be cancelled in 2 minutes.",
"the tick composes summoner + area + the count and its unit word"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_summon_confirms(), 1);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"accepting closes it"
);
}
/// Under a minute the line counts in **seconds**, and the unit word singularises at one — the
/// engine's shared `StaticPopupTimeUnit`, reached through this dialog's own four-argument format.
#[test]
fn the_countdown_line_switches_to_seconds_and_singularises() {
let mut s = setup();
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Elwynn Forest".into(),
time_left_ms: 45_000,
});
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Elwynn Forest. The spell will be cancelled in 45 seconds."
);
// OnShow seeded 45 and the first tick spent 0.1 of it, so 44.9 stands; spending 44.2 more
// lands on 0.7 — inside the last second, which `ceil` reports as 1 and the unit word must
// therefore singularise.
s.run("StaticPopup_OnUpdate(StaticPopup1, 44.2)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Elwynn Forest. The spell will be cancelled in 1 second."
);
}
/// **A name still in flight paints blank and then fills itself in.** The reference does not hold
/// the event back for the name (the event has no arguments to hold), so the first frames render
/// `""` and the getter's next answer lands on the very next tick — which is only true because the
/// countdown branch re-reads the getters rather than caching `StaticPopup_Show`'s arguments.
#[test]
fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
let mut s = setup();
s.set_summon_confirm(SummonConfirmUiState {
summoner: String::new(),
area: "Stormwind City".into(),
time_left_ms: 30_000,
});
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
" wants to summon you to Stormwind City. The spell will be cancelled in 30 seconds.",
"no name yet: a blank, not a raise and not a withheld dialog"
);
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Stormwind City".into(),
time_left_ms: 30_000,
});
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Stormwind City. The spell will be cancelled in 30 seconds.",
"the name query landed; the same open dialog picks it up"
);
}
/// Accept is **disabled in combat and re-enabled out of it**, with the dialog staying up — this
/// entry's OnUpdate is a lock, not a teardown, which is what makes it different from every other
/// confirm in the folder. Cancel is never touched.
#[test]
fn combat_locks_accept_without_taking_the_dialog_down() {
let mut s = setup();
s.set_unit("player", Some(player(false)));
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
.unwrap(),
1,
"out of combat, Accept is live"
);
s.set_unit("player", Some(player(true)));
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
.unwrap(),
0,
"in combat, the answer is locked out"
);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the lock does not take the question away — that is what makes it a lock"
);
assert_eq!(
s.eval::<i64>("return StaticPopup1Button2:IsEnabled()")
.unwrap(),
1,
"and Cancel is never touched"
);
s.set_unit("player", Some(player(false)));
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert_eq!(
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
.unwrap(),
1,
"leaving combat re-arms it, with the same dialog still up"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_summon_confirms(), 1);
}
/// Cancel and ESC both send **nothing**, and so does letting the clock run out: 1.12 has no
/// decline opcode and no `CancelSummon`, so every path but Accept is silence plus the server's own
/// expiry.
#[test]
fn declining_and_expiring_both_send_nothing() {
let mut s = setup();
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_summon_confirms(), 0, "Cancel is silent");
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"hideOnEscape takes it down"
);
assert_eq!(s.take_summon_confirms(), 0, "ESC is silent");
// The window running out: the popup engine's own timeout leg hides the dialog, and this entry
// has no OnCancel, so nothing at all goes to the wire.
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Stormwind City".into(),
time_left_ms: 2_000,
});
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("StaticPopup_OnUpdate(StaticPopup1, 5.0)").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the offer expires off the screen by itself"
);
assert_eq!(s.take_summon_confirms(), 0, "and expiring is silent too");
}
/// The three getters answer the reference's own no-request values — two empty strings and a zero —
/// rather than nil, on a VM nothing has ever pushed to. An addon (or the dialog's own `format`)
/// concatenating a nil here would raise.
#[test]
fn the_getters_answer_empties_before_anything_is_pending() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<String>("return GetSummonConfirmSummoner()")
.unwrap(),
""
);
assert_eq!(
s.eval::<String>("return GetSummonConfirmAreaName()")
.unwrap(),
""
);
assert_eq!(
s.eval::<f64>("return GetSummonConfirmTimeLeft()").unwrap(),
0.0
);
}
/// `GetSummonConfirmTimeLeft()` answers whole **seconds, truncated** — the binding's own
/// `/1000` (`0x48b660`), not a round. It matters because the dialog seeds its countdown from this
/// one call: a rounded-up seed would show one second more than the server is holding.
#[test]
fn the_time_left_getter_truncates_to_whole_seconds() {
let mut s = UiScript::new().unwrap();
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Stormwind City".into(),
time_left_ms: 1_999,
});
assert_eq!(
s.eval::<f64>("return GetSummonConfirmTimeLeft()").unwrap(),
1.0
);
}
+432
View File
@@ -0,0 +1,432 @@
//! Being summoned — the server's question, its dialog, and the one word back (decision 1747).
//!
//! **The law this module exists for: the client's whole part in a summon is a dialog and an echo.**
//! A warlock's Ritual of Summoning, a meeting stone and a GM's `.summon` all converge on the same
//! `SMSG_SUMMON_REQUEST`; the client parks three fields, raises `CONFIRM_SUMMON`, and — only if
//! Accept is pressed — sends `CMSG_SUMMON_RESPONSE` carrying the summoner's guid.
//! **Declining sends nothing at all.** There is no decline opcode and no accept flag in the body:
//! 1.12 has no `CancelSummon` binding (`reference/1.12-globals.tsv` has exactly four names in this
//! family), so a refused summon is silence plus the server's own two-minute expiry. That is why
//! nothing here has a "no" path.
//!
//! The Era surface it drives is the reference's own (`StaticPopup.lua:1336-1357`,
//! `UIParent.lua:126`/`551-552`): the `CONFIRM_SUMMON` event, fired with **no arguments**, and the
//! four engine globals the dialog reads back through ([`benilla_ui::script`]'s `summon` module).
//!
//! Byte-pinned against the reference's own handler `0x5e6140` (the opcode→handler map at the
//! `0x5ab650` registration site gives it `0x2ab`), its latch `0x4963a0`, and the four bindings
//! `0x48b660`/`0x48b6a0`/`0x48b720`/`0x48b770`:
//!
//! 1. **The latch is four globals and nothing structured.** `0x4963a0` writes the guid to
//! `[0xb4e358]/[0xb4e35c]`, the zone id to `[0xb4e354]`, and `OsGetAsyncTimeMs() + delay` to
//! `[0xb4e350]`, then calls `FrameScript_SignalEvent(516)` with no varargs. [`SummonState`] is
//! those four fields, with the deadline on `Time<Real>` for [`crate::death`]'s reason: a window
//! the *server* sends counts down in real seconds, and the virtual clock's `max_delta` clamp
//! would quietly lengthen it.
//! 2. **A dead *or ghost* player is refused the question outright.** Ahead of the latch the handler
//! resolves the active player (`0x468550` → `0x468460` with typemask `0x10` = player) and, if
//! `0x605f30` says yes, returns without latching *or* firing (`5e6194: jne`). So the request
//! does not merely fail to show a dialog: it does not disturb a previously latched one either,
//! which is why [`apply::request`] is the gate and the popup engine's `whileDead` rule is not.
//!
//! **`0x605f30` is not "is dead"** — that reading cost a wrong first cut here. It is
//! `[values+0x40] <= 0` (signed, `605f3b: jle`) **OR** (typemask bit `0x10` **AND** the
//! `PLAYER_FLAGS` ghost bit at `[[obj+0xe68]+8] & 0x10`, `605f3d..605f59`). A ghost's wire
//! health is **1**, so the first leg alone lets a ghost through; the second is what stops it.
//! Both legs are read here, and neither is `unit_reads_dead`'s triple (`0x605f90`), which is
//! a different function with a dyn-flag and stand-state leg this one does not have.
//! A *missing* player object falls through to the latch — a NULL is sent through
//! (`5e6189: je`), so the safe default is to ask.
//! 3. **`GetSummonConfirmAreaName()` is the summoner's zone, straight out of `AreaTable.dbc`**
//! (`0x48b720`: bounds-check, row pointer, `[row + locale*4 + 0x2c]`) — **no parent-zone walk
//! and no GlobalString tail**, unlike the innkeeper question's three-step chain
//! ([`crate::ui_binder::area_name`], `0x5dfe5e`). An id the table cannot name renders `""`.
//! 4. **`GetSummonConfirmSummoner()` answers `""` on a cache miss — and the miss is not inert.**
//! `0x48b6a0` → `DBCache::NameCache::GetRecord 0x55f080` against the cache at `0xc0e228`, else
//! the shared empty string `0x882748`; but a missing record makes `0x55f120` allocate one and
//! **send a name query** (`0x55f1fa`), whose arrival signals nothing. So the real name surfaces
//! only because the popup engine re-reads all three getters every OnUpdate frame — *a client
//! that formats the text once shows an empty summoner forever.* That is what makes
//! `UiPanels.xml`'s `CONFIRM_SUMMON` countdown arm the mechanism rather than a transcription,
//! and it is why the event is not held back for the name the way `DUEL_REQUESTED` (0633) is:
//! that event carries its name as `arg1`, and this one has no argument to hold.
//!
//! [`crate::names::NameCache::resolve`] is the same shape — answer or ask, once — so the feed
//! below asks on every frame the question is live and reports whatever has landed.
//!
//! **Two deviations, both deliberate.**
//!
//! - **The reference never clears the latched guid or zone — nothing in the image does.** Each of
//! the three cells has exactly one writer (the latch) and no clearer, so a second pending summon
//! silently overwrites the first, and a `ConfirmSummon()` typed at the console with nothing
//! pending sends a packet built from whatever is left in the bank. Only the *deadline* is ever
//! zeroed, by `0x49014a` inside the login-side init `0x48fbf0`. We keep the overwrite (it is the
//! behaviour) and drop the whole latch on the session edge ([`end_session_summon`]), refusing to
//! send with no summoner: the reference reaches the same state one step later, at the next
//! login-side init, and nothing can read the bank in between.
//! - We do **not** gate the send on the deadline, and that is the reference's own shape rather than
//! an oversight: `ConfirmSummon 0x48b770` tests nothing and does not clear the latch, so two
//! Accepts are two packets. vmangos is the thing that makes a late one harmless — its handler
//! *ignores the guid entirely* and checks its own `m_summon_expire`.
use benilla_ui::script::{SummonConfirmUiState, UiScript};
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::UiInput;
/// The pending summon question — the reference's four-global bank (module doc, pin 1). Written by
/// the net drain's `SummonRequest` arm through [`apply::request`], read by [`feed_summon`] (which
/// pushes the three getters' answers and fires `CONFIRM_SUMMON`) and by [`drain_summon`] (which
/// turns the dialog's Accept into the response).
#[derive(Resource, Default)]
pub(crate) struct SummonState {
/// Who is summoning — `[0xb4e358]/[0xb4e35c]`. `0` = nothing has ever asked this session.
/// This guid is what goes back on the wire.
summoner: u64,
/// The **summoner's** `AreaTable` id — `[0xb4e354]`. Not ours, and not a map id.
zone: u32,
/// When the offer expires, in `Time<Real>` seconds — `[0xb4e350]`, which the reference stores
/// as an absolute millisecond stamp. `None` = the zeroed deadline: no live offer.
expires_at: Option<f64>,
/// A question the feed still owes the UI. Set per *packet*, not per state edge — being
/// summoned twice by the same warlock is two dialogs, and an edge-diff would swallow the
/// second ([`crate::ui_binder::BinderState`]'s `ask`, for the identical reason).
ask: bool,
}
impl SummonState {
/// The latch `0x4963a0`: park the three fields, arm the deadline, and owe the UI a dialog.
fn latch(&mut self, summoner: u64, zone: u32, delay_ms: u32, now: f64) {
self.summoner = summoner;
self.zone = zone;
self.expires_at = Some(now + f64::from(delay_ms) / 1000.0);
self.ask = true;
}
/// The guid to answer with, if anything has ever asked.
fn pending(&self) -> Option<u64> {
(self.summoner != 0).then_some(self.summoner)
}
/// `0x4963e0` — milliseconds left, clamped at zero, and zero for a deadline that was never
/// armed. The truncation to whole seconds is the *binding's* (`0x48b660`), not this: see
/// [`SummonConfirmUiState::time_left_ms`].
fn time_left_ms(&self, now: f64) -> u32 {
self.expires_at.map_or(0.0, |at| {
((at - now).max(0.0) * 1000.0).min(f64::from(u32::MAX))
}) as u32
}
}
/// Push the three getters' answers every frame, and fire `CONFIRM_SUMMON` for a question the UI is
/// still owed.
///
/// The fire is **unconditional and argument-less** once a packet has asked — the reference's
/// `SignalEvent(516)` carries no varargs, and every getter answers a value on every path, so
/// there is nothing to wait for and nothing to withhold.
fn feed_summon(
script: Option<NonSendMut<UiScript>>,
mut summon: ResMut<SummonState>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
areas: Option<Res<AreaTableRes>>,
// Real, not virtual: the deadline was stamped on this clock by the net drain, and the server's
// window runs in real seconds ([`crate::death`]'s reasoning, decision 0846).
time: Res<Time<Real>>,
) {
let Some(mut script) = script else {
return;
};
// Nothing has ever asked and nothing is owed: the getters' defaults are already exactly what
// the reference answers over a zeroed bank, so there is no snapshot to push and no name to
// resolve. **Not `pending()`** — the two conditions are deliberately separate, because a
// request carrying a zero guid still latches and still fires in the reference (the handler
// does not inspect the guid), and would otherwise leave `ask` owed forever.
if summon.summoner == 0 && !summon.ask {
return;
}
// A cache miss issues the name query and reports `""` this frame — the reference's own
// `NameCache::GetRecord` shape (module doc, pin 4). A zero guid is never asked about: it names
// nobody, and the reference's own cache lookup would answer the same `""`.
let summoner_name = match summon.pending() {
Some(guid) => names
.resolve(guid, &commands)
.unwrap_or_default()
.to_string(),
None => String::new(),
};
script.set_summon_confirm(SummonConfirmUiState {
summoner: summoner_name,
area: area_name(summon.zone, areas.as_deref()),
time_left_ms: summon.time_left_ms(time.elapsed_secs_f64()),
});
if !summon.ask {
return;
}
summon.ask = false;
// **The interact chain's middle link** (tag `summon`, the `use` kit's shape in
// [`crate::target::click`]). "I got summoned and nothing happened" spans three links — the
// packet landing, this event, and the response going out — and only one run should be needed
// to say which is dead. The name is traced because a blank one is the most likely-looking
// symptom that is in fact correct.
if benilla_assets::trace::enabled_for("summon") {
benilla_assets::trace::line(
"summon",
&format!(
"fire CONFIRM_SUMMON summoner={:#x} zone={}",
summon.summoner, summon.zone
),
);
}
script.fire_event("CONFIRM_SUMMON", Vec::new());
}
/// `GetSummonConfirmAreaName()`'s lookup — the row's own name or `""` (module doc, pin 3).
///
/// Split out and taking a bare id so the "no parent walk, no GlobalString tail" half can be tested
/// against the real `AreaTable` beside [`crate::ui_binder`]'s three-step chain, which is the thing
/// it is most likely to be mistaken for.
fn area_name(zone: u32, areas: Option<&AreaTableRes>) -> String {
areas
.and_then(|a| a.0.name(zone))
.unwrap_or_default()
.to_string()
}
/// Turn the dialog's Accept into `CMSG_SUMMON_RESPONSE`.
///
/// Gated only on *something having asked* — not on the deadline, which is the reference's own
/// shape (module doc, deviation 2). A `ConfirmSummon()` with nothing ever latched would otherwise
/// put a zero guid on the wire, which no server can do anything with.
fn drain_summon(
script: Option<NonSendMut<UiScript>>,
summon: Res<SummonState>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
let confirms = script.take_summon_confirms();
if confirms == 0 {
return;
}
let Some(summoner) = summon.pending() else {
debug!("ui_summon: ConfirmSummon() with nothing latched — nothing to answer with");
return;
};
if benilla_assets::trace::enabled_for("summon") {
benilla_assets::trace::line(
"summon",
&format!("SEND CMSG_SUMMON_RESPONSE summoner={summoner:#x} n={confirms}"),
);
}
for _ in 0..confirms {
let _ = commands.0.send(ClientCommand::SummonResponse { summoner });
}
}
/// The latch is the session's, and dies with it (module doc, deviation 1).
///
/// `DisconnectedMessage` is the one total edge — a socket death, a kick and a `/logout` all reach
/// it — which is the same edge [`crate::death`]'s world-scoped memory resets on, so the two stay in
/// lockstep by construction. A `/reload` must NOT clear this: the summon window is the engine's,
/// and the reference's survives one.
fn end_session_summon(
mut msgs: MessageReader<crate::net::DisconnectedMessage>,
mut summon: ResMut<SummonState>,
) {
if msgs.read().next().is_some() {
*summon = SummonState::default();
}
}
/// The net drain's `SessionEvent::SummonRequest` arm, factored here so the wire law lives beside
/// the state it drives.
pub(crate) mod apply {
use bevy::prelude::debug;
use super::SummonState;
/// `SMSG_SUMMON_REQUEST` — latch the offer and owe the UI a dialog, unless we are dead or a
/// ghost.
///
/// `dead_or_ghost` is the reference's `0x605f30` over the *active player* — **both** of its
/// legs (module doc, pin 2) — read by the caller because that is where the drain already has
/// the descriptor. **False when there is no player object at all**, which is the reference's
/// own default (`5e6189: je` sends a NULL through to the latch). A refusal touches nothing:
/// that is the whole point of gating here rather than at the dialog — an ill-timed summon must
/// not overwrite a live one.
pub(crate) fn request(
summoner: u64,
zone: u32,
delay_ms: u32,
dead_or_ghost: bool,
now: f64,
summon: &mut SummonState,
) {
// **The interact chain's first link** (tag `summon`): the packet, with the gate's verdict
// beside it. A drop here is the reference's behaviour and looks exactly like a broken
// client, so it is the one refusal that must never be silent in a trace.
if benilla_assets::trace::enabled_for("summon") {
benilla_assets::trace::line(
"summon",
&format!(
"recv SMSG_SUMMON_REQUEST summoner={summoner:#x} zone={zone} \
delay_ms={delay_ms} dead_or_ghost={dead_or_ghost}"
),
);
}
if dead_or_ghost {
debug!("ui_summon: summon request from {summoner:#x} dropped — we are dead or a ghost");
return;
}
summon.latch(summoner, zone, delay_ms, now);
}
}
/// The summon flow: the question's feed and its one answer.
pub(crate) struct UiSummonPlugin;
impl Plugin for UiSummonPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SummonState>().add_systems(
Update,
(
// Before the feed, so the frame a session ends is already a frame the feed sees no
// memory of the last one ([`crate::death`]'s ordering, for the same reason).
end_session_summon.before(feed_summon),
feed_summon.before(UiInput),
drain_summon.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A second summon is a second dialog. The `ask` flag is per packet precisely so a
/// decline-then-be-summoned-again (which never passes through "nothing pending") is not
/// swallowed by a state diff.
#[test]
fn asking_twice_owes_two_dialogs() {
let mut summon = SummonState::default();
assert_eq!(summon.pending(), None);
summon.latch(0x2a, 1519, 120_000, 100.0);
assert!(summon.ask);
summon.ask = false; // the feed fired the first dialog
assert_eq!(summon.pending(), Some(0x2a), "the guid outlives the fire");
summon.latch(0x2a, 1519, 120_000, 200.0);
assert!(summon.ask, "the same summoner asking again owes a dialog");
}
/// The countdown is the packet's own window, clamped at zero and zero before anything asks —
/// the reference's `0x4963e0`, whose three legs are exactly these three cases.
#[test]
fn the_countdown_runs_from_the_packets_delay_and_never_goes_negative() {
let mut summon = SummonState::default();
assert_eq!(summon.time_left_ms(0.0), 0, "a zeroed deadline reads 0");
summon.latch(0x2a, 1519, 120_000, 100.0);
assert_eq!(summon.time_left_ms(100.0), 120_000);
assert_eq!(summon.time_left_ms(160.0), 60_000);
assert_eq!(summon.time_left_ms(220.0), 0, "expired, not negative");
assert_eq!(summon.time_left_ms(9_999.0), 0);
}
/// **The gate refuses without disturbing anything** (module doc, pin 2) — the half that would
/// be lost by putting the test in the dialog instead: a summon arriving while dead or a ghost
/// must leave a *live* question exactly as it found it.
#[test]
fn a_summon_that_arrives_while_refused_latches_nothing() {
let mut summon = SummonState::default();
apply::request(0x2a, 1519, 120_000, true, 100.0, &mut summon);
assert_eq!(summon.pending(), None);
assert!(!summon.ask);
assert_eq!(summon.time_left_ms(100.0), 0);
// A live question, then a second request while refused: the first survives untouched.
apply::request(0x2a, 1519, 120_000, false, 100.0, &mut summon);
summon.ask = false;
apply::request(0x99, 1, 120_000, true, 150.0, &mut summon);
assert_eq!(
summon.pending(),
Some(0x2a),
"the live offer is undisturbed"
);
assert_eq!(summon.zone, 1519);
assert!(!summon.ask, "and no second dialog is owed");
}
/// **A GHOST is refused, and a ghost is not dead** — `0x605f30`'s second leg, and the one this
/// module had wrong on its first cut. A released player's wire health is `1`, so
/// `unit_is_dead()` is FALSE for them; only the `PLAYER_FLAGS` ghost bit stops the latch.
///
/// This guards the **call site's** predicate (`net::apply`'s `SummonRequest` arm), which is
/// exactly what would regress if someone simplified it back to the single accessor the
/// parameter's old name suggested.
#[test]
fn a_ghost_is_refused_even_though_a_ghost_is_not_dead() {
// A released player as the wire describes one: health 1 of 100, PLAYER_FLAGS ghost bit.
const HEALTH: u16 = 22;
const MAXHEALTH: u16 = 28;
const PLAYER_FLAGS: u16 = 190;
let ghost = benilla_protocol::ObjectFields::from_pairs(&[
(HEALTH, 1),
(MAXHEALTH, 100),
(PLAYER_FLAGS, 0x10),
]);
assert!(
!ghost.unit_is_dead(),
"a ghost has health — which is why the first leg alone lets one through"
);
assert!(ghost.player_is_ghost());
assert!(
ghost.unit_is_dead() || ghost.player_is_ghost(),
"the arm's predicate is the OR of both legs, and it must refuse a ghost"
);
// The corpse case, for the other leg: dead, not yet released, no ghost bit.
let corpse = benilla_protocol::ObjectFields::from_pairs(&[(HEALTH, 0), (MAXHEALTH, 100)]);
assert!(corpse.unit_is_dead() && !corpse.player_is_ghost());
}
/// `GetSummonConfirmAreaName()` is a bare `AreaTable` read: the row's name, else `""` — **no**
/// parent-zone hop and **no** GlobalString tail. Guarding the exact difference from
/// [`crate::ui_binder`]'s chain, which is the thing this is most likely to drift into.
/// Skips without client data.
#[test]
fn the_area_name_is_the_bare_row_with_no_fallback_chain() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let areas = AreaTableRes(
benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc"),
);
assert_eq!(area_name(1519, Some(&areas)), "Stormwind City");
// 186 is Dolanaar, a SUB-area whose parent zone is Teldrassil. The binder's chain would
// reach a name for an unnamed leaf by walking up; this one never walks, so the only thing
// that matters is that the row itself answers.
assert_eq!(area_name(186, Some(&areas)), "Dolanaar");
assert_eq!(area_name(0xffff, Some(&areas)), "", "no row, no name");
assert_eq!(area_name(1519, None), "", "no catalog, no name");
}
/// Accepting with nothing latched sends nothing — the one place we decline to reproduce the
/// reference's un-cleared bank (module doc, deviation 1).
#[test]
fn nothing_pending_means_nothing_to_answer_with() {
let summon = SummonState::default();
assert_eq!(summon.pending(), None);
}
}
@@ -23,8 +23,8 @@ use clap::Parser;
use probes::{
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, MountTele,
OpenItem, Probe, QueryNames, Quest, QuestItem, QuestLog, QuestTimer, Speed, Spells, Spirit,
SwapPackSlots, UsePackSlot, Vendor, WorldState,
OpenItem, Probe, QueryNames, Quest, QuestItem, QuestLog, QuestTimer, SelfRes, Speed, Spells,
Spirit, SwapPackSlots, UsePackSlot, Vendor, WorldState,
};
use world::{DeathArc, Tracked, World};
@@ -247,6 +247,16 @@ struct Cli {
#[arg(long)]
death: bool,
/// Live-verify the self-resurrect wire (decision 1746): arm a shaman's Reincarnation with GM
/// commands (`.cooldown`, `.learn 20608`, `.additem 17030` — vmangos's gate asks no class
/// question), die and **stay dead-unreleased** (the DEATH dialog's state, where the soulstone
/// button lives), require `PLAYER_SELF_RES_SPELL` to arrive carrying the effect spell 21169,
/// send `CMSG_SELF_RES`, and require both that the field clears and that we stand back up
/// without ever having been a ghost. Recommend `--seconds 30`. Needs a GM account; leaves the
/// character alive. Mutually exclusive with `--death`/`--spirit`, which release the spirit.
#[arg(long)]
self_res: bool,
/// Live-verify the spirit-healer res + the 25% durability loss's wire (director-reported:
/// "durability still 100% after spirit-healer rez"): GM-repair to a full baseline, die and
/// release (the --death staging), teleport onto the graveyard's Spirit Healer, send
@@ -348,8 +358,12 @@ fn main() -> Result<()> {
// The shared world state every probe reads (identity, entity tracker, item/vendor stores, the
// session-keeping acks); the DeathArc scenario machinery is staged when --death/--spirit is set.
let mut world = World::new(character);
if cli.death || cli.spirit {
world.death_arc = Some(DeathArc::default());
if cli.death || cli.spirit || cli.self_res {
world.death_arc = Some(DeathArc {
// --self-res tests the dead-UNRELEASED state, so the arc must not repop on its own.
hold_release: cli.self_res,
..DeathArc::default()
});
}
// The probe registry: one entry per flag, in today's stream-loop/verify block order (so poll,
@@ -420,6 +434,9 @@ fn main() -> Result<()> {
if cli.spirit {
probes.push(Box::new(Spirit::default()));
}
if cli.self_res {
probes.push(Box::new(SelfRes::default()));
}
if cli.worldstate {
probes.push(Box::new(WorldState::default()));
}
@@ -74,6 +74,7 @@ mod quest;
mod quest_item;
mod questlog;
mod questtimer;
mod self_res;
mod speed;
mod spells;
mod spirit;
@@ -97,6 +98,7 @@ pub(crate) use quest::Quest;
pub(crate) use quest_item::QuestItem;
pub(crate) use questlog::QuestLog;
pub(crate) use questtimer::QuestTimer;
pub(crate) use self_res::SelfRes;
pub(crate) use speed::Speed;
pub(crate) use spells::Spells;
pub(crate) use spirit::Spirit;
@@ -0,0 +1,176 @@
//! `--self-res`: the self-resurrect wire (decision 1746) — `PLAYER_SELF_RES_SPELL` arriving at the
//! death, `CMSG_SELF_RES` spending it, and the field zeroing as we stand back up.
//!
//! The whole mechanism is one private descriptor field and one bodyless opcode, so the only way to
//! be sure of either is to watch a real death. The shared [`crate::world::DeathArc`] does the dying
//! — with `hold_release` set, because the button being tested lives on the DEATH dialog and that
//! dialog only exists *before* the release.
//!
//! **Reincarnation, not a soulstone**, because a soulstone needs a second character casting on us
//! while a shaman's passive needs only what a GM account can grant itself: vmangos's
//! `Player::SelectResurrectionSpellId` gates the Reincarnation arm on `HasSpell(20608)` +
//! `IsSpellReady(21169)` + `HasItemCount(17030, 1)` and asks **no class question**, so the staging
//! below arms it on whatever body the slot's probe account has. The two paths converge one line
//! later — both write their effect id into the same field — so the field, the send and the
//! resurrection are the same wire either way.
use anyhow::{bail, Context, Result};
use benilla_protocol::SessionEvent;
use crate::probes::{Ctx, Probe};
/// Reincarnation's learnable passive — the `HasSpell` half of the server's gate.
const REINCARNATION_PASSIVE: u32 = 20608;
/// Reincarnation's *effect* spell — what the server writes into `PLAYER_SELF_RES_SPELL`, and what
/// `Spell.dbc` names **"Reincarnation"** (the DEATH dialog's button text).
const REINCARNATION_EFFECT: u32 = 21169;
/// Ankh — the reagent the same gate counts.
const ITEM_ANKH: u32 = 17030;
#[derive(Default)]
pub(crate) struct SelfRes {
/// The first non-zero `PLAYER_SELF_RES_SPELL` seen, and whether we were already at 0 health
/// when it landed — the ordering question the DEATH dialog's `OnShow` read depends on.
self_res_spell: Option<u32>,
self_res_after_death: bool,
sent: bool,
/// `PLAYER_SELF_RES_SPELL` read back as zero *after* the send — the server spending it.
field_cleared: bool,
/// Health back above 0 after the send, without ever having been a ghost.
revived_without_releasing: bool,
}
impl Probe for SelfRes {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// Arm the passive path. Reincarnation has a one-hour cooldown and a **successful run is
// what sets it**, so without this the probe passes once and then fails for an hour — which
// is exactly what it did, and the failure reads as "the server refused the gate" rather
// than "you already proved this". `.cooldown clear` (not `.cooldown`, which is a bare
// subcommand table and silently does nothing) removes all of the selected unit's
// cooldowns; with nothing selected `ChatHandler::GetSelectedUnit` falls back to self.
// The Ankh goes in every run because the resurrection spends it.
cx.session.send_chat(".cooldown clear")?;
cx.session
.send_chat(&format!(".learn {REINCARNATION_PASSIVE}"))?;
cx.session.send_chat(&format!(".additem {ITEM_ANKH} 1"))?;
println!(
"sent GM: .cooldown clear; .learn {REINCARNATION_PASSIVE} (Reincarnation passive); \
.additem {ITEM_ANKH} (Ankh)"
);
Ok(())
}
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Spend it once the death has landed AND the field has arrived — the same two facts the
// DEATH dialog's button2 waits on (`HasSoulstone()` non-nil while the popup is up).
let died = cx
.world
.death_arc
.as_ref()
.is_some_and(|a| a.died_at.is_some());
if !self.sent && died && self.self_res_spell.is_some() {
cx.session.self_res()?;
println!("sent CMSG_SELF_RES (the DEATH dialog's soulstone button)");
self.sent = true;
}
Ok(())
}
fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> {
let SessionEvent::ObjectValues { guid, fields } = ev else {
return Ok(());
};
if *guid != cx.world.self_guid {
return Ok(());
}
// Read the DELTA for the arrival, not the merged store: the question is which packet
// carried the field, and a merged read cannot tell "arrived now" from "arrived earlier".
if let Some(spell) = fields.player_self_res_spell() {
if self.self_res_spell.is_none() {
self.self_res_spell = Some(spell);
self.self_res_after_death = cx
.world
.death_arc
.as_ref()
.is_some_and(|a| a.died_at.is_some());
println!(
"PLAYER_SELF_RES_SPELL → {spell} (arrived {} the health→0 flush)",
if self.self_res_after_death {
"after"
} else {
"before"
}
);
}
} else if self.sent && !self.field_cleared {
// Zero and absent are the same `None` here, so the clear is only meaningful once the
// MERGED store also reads none — an unrelated delta must not be mistaken for it.
if cx
.world
.self_fields
.as_ref()
.is_some_and(|sf| sf.player_self_res_spell().is_none())
{
self.field_cleared = true;
println!("PLAYER_SELF_RES_SPELL cleared — the server spent it");
}
}
if self.sent && !self.revived_without_releasing {
if let Some(sf) = &cx.world.self_fields {
if sf.unit_health().is_some_and(|h| h > 0) && !sf.player_is_ghost() {
self.revived_without_releasing = true;
println!(
"alive again at {} hp, never a ghost — self-resurrected in place",
sf.unit_health().unwrap_or(0)
);
}
}
}
Ok(())
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
let arc = cx
.world
.death_arc
.as_ref()
.expect("death_arc present when --self-res is set");
arc.death_pos.context(
"--self-res: `.die` never dropped our health to 0 — is the account gmlevel ≥ 2?",
)?;
if arc.ghost_seen {
bail!(
"--self-res: released to a ghost — `hold_release` did not hold, and the state \
under test (dead, unreleased) was never occupied"
);
}
let spell = self.self_res_spell.context(
"--self-res: PLAYER_SELF_RES_SPELL never arrived — the server refused the \
Reincarnation gate (`.learn`/`.additem` rejected, or the effect spell is still on \
cooldown from a previous run and `.cooldown clear` did not take)",
)?;
if spell != REINCARNATION_EFFECT {
bail!(
"--self-res: PLAYER_SELF_RES_SPELL = {spell}, expected the Reincarnation EFFECT \
{REINCARNATION_EFFECT} (the passive {REINCARNATION_PASSIVE} is what we learn; the \
effect is what the field carries and what Spell.dbc names on the button)"
);
}
if !self.field_cleared {
bail!("--self-res: PLAYER_SELF_RES_SPELL never cleared after CMSG_SELF_RES");
}
if !self.revived_without_releasing {
bail!("--self-res: never came back alive after CMSG_SELF_RES");
}
println!(
"--self-res OK: field {spell} arrived {} the health-zero flush, CMSG_SELF_RES spent \
it, resurrected dead-unreleased without ever releasing",
if self.self_res_after_death {
"after"
} else {
"before"
}
);
Ok(())
}
}
@@ -44,6 +44,10 @@ pub(crate) struct DeathArc {
/// ghost-clear check keys off this instead of either probe's own flag, so the two are decoupled.
pub(crate) revive_initiated: bool,
pub(crate) revived_seen: bool,
/// **Stop at dead-unreleased** — `--self-res` sets it, and nothing else does (decision 1746).
/// The soulstone button lives on the DEATH dialog, which only exists *before* the release; an
/// arc that repops on its own would walk straight past the state under test.
pub(crate) hold_release: bool,
}
/// Everything more than one probe reads, plus the session-keeping acks and the [`DeathArc`].
@@ -182,7 +186,7 @@ impl World {
// --death: once we've hit 0 health AND the server has force-rooted us (the wire doesn't
// guarantee which arrives first — see the MoveRoot/ObjectValues arms below), release the
// spirit: the RELEASE SPIRIT button's `CMSG_REPOP_REQUEST` (decision 0308 §1).
if !arc.repop_sent && arc.died_at.is_some() && arc.rooted_seen {
if !arc.hold_release && !arc.repop_sent && arc.died_at.is_some() && arc.rooted_seen {
session.repop_request()?;
println!("sent CMSG_REPOP_REQUEST (release spirit)");
arc.repop_sent = true;
+11
View File
@@ -412,6 +412,17 @@ pub enum SessionEvent {
/// question the `CONFIRM_BINDER` dialog puts on screen. `binder` is the innkeeper's guid, and
/// it must be echoed in `CMSG_BINDER_ACTIVATE` for the bind to happen at all (decision 1331).
BinderConfirm { binder: u64 },
/// Someone is asking to summon us (`SMSG_SUMMON_REQUEST`) — the question the `CONFIRM_SUMMON`
/// dialog puts on screen (decision 1747). `summoner` is the guid to echo in
/// `CMSG_SUMMON_RESPONSE` and the one the dialog names; `zone` is the **summoner's**
/// `AreaTable` id, the place the dialog says you would be pulled to; `delay_ms` is how long
/// the server will hold the offer before auto-declining it. Accepting is the only packet in
/// the flow — declining sends nothing (there is no decline opcode).
SummonRequest {
summoner: u64,
zone: u32,
delay_ms: u32,
},
/// A class trainer is asking whether to unlearn every talent (`MSG_TALENT_WIPE_CONFIRM`,
/// inbound) — the question the `CONFIRM_TALENT_WIPE` dialog puts on screen, carrying the
/// `trainer` to answer with and the `cost` in copper its money frame shows. Answering means
@@ -723,6 +723,15 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::GmTicketStatusUpdate { status }]
}
ServerPacket::BinderConfirm { binder } => vec![SessionEvent::BinderConfirm { binder }],
ServerPacket::SummonRequest {
summoner,
zone,
delay_ms,
} => vec![SessionEvent::SummonRequest {
summoner,
zone,
delay_ms,
}],
ServerPacket::TalentWipeConfirm { trainer, cost } => {
vec![SessionEvent::TalentWipeConfirm { trainer, cost }]
}
@@ -57,6 +57,7 @@ mod social;
mod spellbook;
mod spells;
mod stable;
mod summon;
mod taxi;
mod trade;
mod trainer;
@@ -221,6 +222,7 @@ pub use stable::{
buy_stable_slot, list_stabled_pets, stable_pet, stable_result, stable_swap_pet, unstable_pet,
StabledPet,
};
pub use summon::{summon_response, SummonRequest};
pub use taxi::{
activate_taxi, activate_taxi_express, taxi_node_status_query, taxi_query_available_nodes,
taxi_reply, TaxiMask,
@@ -846,6 +846,18 @@ pub const CMSG_LEARN_TALENT: u16 = 0x0251; // 593
/// leg puts the *latched* trainer guid on the wire. Bodies in [`super::progression`].
pub const MSG_TALENT_WIPE_CONFIRM: u16 = 0x02AA; // 682
/// The player-summon pair — the question and the accept (VERIFIED vmangos `Opcodes_1_12_1.h`:
/// 683/684 + `Opcodes.cpp`'s `HandleSummonResponseOpcode` registration; decision 1747). A
/// warlock's Ritual of Summoning, a meeting stone and a GM `.summon` all arrive as the same
/// inbound question, and the client answers only to say **yes**: there is no decline opcode and no
/// accept flag in the body, so a declined summon is silence and the server's own two-minute timer.
///
/// Byte-verified client-side as well as against vmangos (wow-re: the `0x5ab650` registration site
/// maps `0x2ab` to handler `0x5e6140`, and the `ConfirmSummon` binding `0x48b770` writes `0x2ac`).
/// Bodies in [`super::summon`].
pub const SMSG_SUMMON_REQUEST: u16 = 0x02AB; // 683
pub const CMSG_SUMMON_RESPONSE: u16 = 0x02AC; // 684
/// Unlearn (abandon) a whole skill line — the skills pane's red circle-slash (VERIFIED vmangos
/// `Opcodes.cpp` handler registration + `Opcodes_1_9_4.h`: 514). Body in
/// [`super::skills::unlearn_skill`]; no ack — the removal returns as a `PLAYER_SKILL_INFO`
@@ -916,6 +928,12 @@ pub const MSG_CORPSE_QUERY: u16 = 0x0216; // 534
pub const CMSG_SPIRIT_HEALER_ACTIVATE: u16 = 0x021C; // 540
pub const SMSG_SPIRIT_HEALER_CONFIRM: u16 = 0x0222; // 546
pub const SMSG_CORPSE_RECLAIM_DELAY: u16 = 0x0269; // 617
/// Self-resurrect — the DEATH popup's soulstone/Reincarnation button (`UseSoulstone()`, decision
/// 1746). **EMPTY body** (VERIFIED vmangos `Opcodes_1_12_1.h:692` = 691, handler
/// `WorldSession::HandleSelfResOpcode(NullClientPacket const&)`, `SpellHandler.cpp:461`): the
/// server casts whatever `PLAYER_SELF_RES_SPELL` holds on us and zeroes the field. There is no
/// answer packet — the resurrection arrives as ordinary descriptor deltas, exactly like a reclaim.
pub const CMSG_SELF_RES: u16 = 0x02B3; // 691
/// Sent with the 10% durability loss a natural (non-PvP) death applies (VERIFIED vmangos
/// `Opcodes_1_12_1.h` 701 + `Unit.cpp:1170-1182` — an EMPTY body; the client's cue for the red
/// "Your equipped items suffer a 10%% durability loss." error line).
@@ -270,6 +270,15 @@ pub enum ServerPacket {
trainer: u64,
cost: u32,
},
/// `SMSG_SUMMON_REQUEST` — someone is *asking* to pull us to them (decision 1747): a
/// warlock's ritual, a meeting stone, a GM. The twin of [`Self::BinderConfirm`] in shape —
/// nothing moves until `CMSG_SUMMON_RESPONSE` goes back — and its opposite in teardown:
/// there is no decline opcode, so the question expires rather than being answered no.
SummonRequest {
summoner: u64,
zone: u32,
delay_ms: u32,
},
/// `SMSG_PLAYERBOUND` — the bind took: who bound us and the AreaTable id we are now bound in.
/// Arrives beside [`Self::BindPoint`], which carries the same area id plus the position; this
/// one exists for the "X is now your home" acknowledgement.
@@ -1436,6 +1445,7 @@ impl ServerPacket {
ServerPacket::GmTicketStatusUpdate { .. } => "SMSG_GM_TICKET_STATUS_UPDATE".into(),
ServerPacket::BinderConfirm { .. } => "SMSG_BINDER_CONFIRM".into(),
ServerPacket::PlayerBound { .. } => "SMSG_PLAYERBOUND".into(),
ServerPacket::SummonRequest { .. } => "SMSG_SUMMON_REQUEST".into(),
ServerPacket::TalentWipeConfirm { .. } => "MSG_TALENT_WIPE_CONFIRM".into(),
ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(),
ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(),
+12 -2
View File
@@ -14,8 +14,8 @@ use super::{
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
duel, gameobject, gm_ticket, gossip, group, guild, items, loot, mail, mirror_timer,
monster_move, movement, opcode, page_text, pet, petition, progression, pvp, quest, social,
spellbook, spells, stable, taxi, trade, trainer, update_object, vendor, world_state, Character,
CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor, world_state,
Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -348,6 +348,16 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_BINDER_CONFIRM => ServerPacket::BinderConfirm {
binder: binder::read_binder_confirm(&mut r)?,
},
// The summon question (decision 1747). Three fields, and the client's whole answer is
// the guid back — see `super::summon` for the byte pin on both bodies.
opcode::SMSG_SUMMON_REQUEST => {
let ask = summon::read_summon_request(&mut r)?;
ServerPacket::SummonRequest {
summoner: ask.summoner,
zone: ask.zone,
delay_ms: ask.delay_ms,
}
}
// The talent twin of the confirm above, on a two-way `MSG_` opcode: this direction is the
// question (guid + cost); the answer we send back carries the guid alone (decision 1580).
opcode::MSG_TALENT_WIPE_CONFIRM => {
@@ -0,0 +1,107 @@
//! The player-summon family — the server's question and the client's one-word answer
//! (opcodes 683 and 684; decision 1747).
//!
//! Two packets, and the asymmetry between them is the whole story. A warlock's Ritual of
//! Summoning, a meeting stone, or a GM's `.summon` all end in the *server* asking the summoned
//! player `SMSG_SUMMON_REQUEST`; the client parks the three fields and puts the `CONFIRM_SUMMON`
//! dialog up. Accepting sends `CMSG_SUMMON_RESPONSE` carrying **the summoner's guid and nothing
//! else** — there is no accept/decline byte in 1.12, because **declining sends no packet at all**
//! and the server auto-declines on its own timer.
//!
//! | opcode | direction | body |
//! |---|---|---|
//! | `SMSG_SUMMON_REQUEST` 0x2ab | in | `u64` summoner guid + `u32` zone id + `u32` delay ms |
//! | `CMSG_SUMMON_RESPONSE` 0x2ac | out | `u64` summoner guid |
//!
//! **Byte-pinned client-side**, not inferred from the server: the 1.12.1 handler `0x5e6140` (the
//! `0x5ab650` registration site maps `0x2ab` to it) reads exactly three fields in this order —
//! `CDataStore::GetGuid 0x4190b0`, `CDataStore::GetInt32 0x418e30`, `CDataStore::GetUInt32_2
//! 0x418fb0` — and the accept binding `ConfirmSummon 0x48b770` writes opcode `0x2ac` followed by
//! one `CDataStore::PutGuid 0x418370` and sends (`ClientServices::Send 0x5ab630`). Nothing else
//! goes into either body.
//!
//! vmangos agrees on both, independently: `Server/Packets/Misc.cpp`'s
//! `SummonRequest::AppendBodyTo` writes `summonerGuid; zoneId; autoDeclineDelay`, and
//! `SummonResponse::ReadFromWorldPacket` reads **only** `summonerGuid` — then ignores it, calling
//! `Player::SummonIfPossible(true)` off its own `m_summon_expire` window
//! (`Handlers/MovementHandler.cpp`). So the guid is an echo the server does not check; what makes
//! a stale accept harmless is the server's timer, not the guid.
use std::io;
use crate::wire::{read_u32_le, read_u64_le};
/// `SMSG_SUMMON_REQUEST` — someone is asking to pull us to them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummonRequest {
/// Who is summoning. Echoed back in [`summon_response`], and it is the guid
/// `GetSummonConfirmSummoner()` resolves through the name cache.
pub summoner: u64,
/// The **summoner's** zone — an `AreaTable.dbc` id, which is the whole of what
/// `GetSummonConfirmAreaName()` looks up. Not our own zone, and not a map id.
pub zone: u32,
/// Milliseconds until the server auto-declines (vmangos `MAX_PLAYER_SUMMON_DELAY * 1000`, two
/// minutes). The client turns it into a deadline the moment the packet lands and counts down
/// against it; the dialog takes itself off screen when it runs out.
pub delay_ms: u32,
}
/// Read `SMSG_SUMMON_REQUEST`: guid, zone, delay — see the module header for the byte pin.
pub(super) fn read_summon_request(r: &mut &[u8]) -> io::Result<SummonRequest> {
Ok(SummonRequest {
summoner: read_u64_le(r)?,
zone: read_u32_le(r)?,
delay_ms: read_u32_le(r)?,
})
}
/// Body of `CMSG_SUMMON_RESPONSE`: the summoner's full 8-byte guid, and nothing after it.
///
/// **Decorative on vmangos, and still the right thing to send**: its handler reads the guid and
/// never looks at it again. The client's own `ConfirmSummon 0x48b770` puts the latched guid here,
/// so a server that *does* check it (or a future one that must distinguish two pending summons)
/// gets what the reference would have sent.
pub fn summon_response(summoner_guid: u64) -> Vec<u8> {
summoner_guid.to_le_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
/// The request body is the three fields in wire order, and the reader must not reorder the
/// two `u32`s — a swapped pair would name the zone by a millisecond count and count down two
/// minutes' worth of area ids, both of which read as "the dialog is just wrong".
#[test]
fn the_request_body_is_guid_then_zone_then_delay() {
let mut bytes = 0xF130_0000_0001_2345u64.to_le_bytes().to_vec();
bytes.extend_from_slice(&1519u32.to_le_bytes()); // Stormwind City
bytes.extend_from_slice(&120_000u32.to_le_bytes()); // two minutes
assert_eq!(
read_summon_request(&mut &bytes[..]).unwrap(),
SummonRequest {
summoner: 0xF130_0000_0001_2345,
zone: 1519,
delay_ms: 120_000,
}
);
}
/// A truncated body is an error, not a zero-filled request: a half-read packet that latched
/// would put a dialog up naming nobody, for no time.
#[test]
fn a_short_request_body_is_an_error() {
let bytes = [0u8; 15];
assert!(read_summon_request(&mut &bytes[..]).is_err());
}
/// The answer is one little-endian guid — the shape vmangos's `SummonResponse` reads back,
/// and byte-for-byte what `ConfirmSummon 0x48b770` puts on the wire.
#[test]
fn the_response_body_is_one_little_endian_guid_and_nothing_else() {
assert_eq!(
summon_response(0x0000_0000_0000_2a01),
vec![0x01, 0x2a, 0, 0, 0, 0, 0, 0]
);
}
}
@@ -461,9 +461,17 @@ const FIELD_PLAYER_TRACK_CREATURES: u16 = 1104;
const FIELD_PLAYER_TRACK_RESOURCES: u16 = 1105;
const FIELD_PLAYER_AMMO_ID: u16 = 1223; // UNIT_END+0x40B, INT — the equipped ammo item id
// (`Player.cpp:7656` etc., `Player::SetUInt32Value`).
// PLAYER_FLAGS = UNIT_END(188) + 0x2 (VERIFIED vmangos `UpdateFields_1_12_1.h:120`; the duel
// arbiter guid takes +0x0/+0x1). Bit 0x10 = PLAYER_FLAGS_GHOST (`Player.h:319`), set/cleared by
// the ghost aura 8326's HandleAuraGhost at release/resurrect (decision 0308 §1).
// PLAYER_SELF_RES_SPELL = UNIT_END(188) + 0x40C (INT, **PRIVATE** — self only), the spell id the
// server will cast if we ask to self-resurrect: the whole soulstone/Reincarnation mechanism, on
// the wire as one dword (decision 1746). Contiguous past the ammo id (1223 + 1 = 1224 ✓), and
// cross-checked two ways in vmangos because its two tables disagree: the enum
// `UpdateFields_1_12_1.h:284` gives `UNIT_END + 0x40C` (= 1224) while its trailing *comment* says
// `// 0x4C2` — the comment is stale; the parallel descriptor table `UpdateFields_1_12_1.cpp:277`
// carries the absolute index `0x4C8` = **1224**, agreeing with the arithmetic.
const FIELD_PLAYER_SELF_RES_SPELL: u16 = 1224;
// PLAYER_FLAGS = UNIT_END(188) + 0x2 (VERIFIED vmangos `UpdateFields_1_12_1.h:120`; the duel
// arbiter guid takes +0x0/+0x1). Bit 0x10 = PLAYER_FLAGS_GHOST (`Player.h:319`), set/cleared by
// the ghost aura 8326's HandleAuraGhost at release/resurrect (decision 0308 §1).
const FIELD_PLAYER_FLAGS: u16 = 190;
// PLAYER_DUEL_ARBITER = UNIT_END(188) + 0x0 (GUID, PUBLIC), PLAYER_DUEL_TEAM = UNIT_END + 0x8
// (INT, PUBLIC) — VERIFIED vmangos `UpdateFields_1_12_1.h:119,126`. The arbiter opens the player
@@ -395,6 +395,29 @@ impl ObjectFields {
pub fn player_ammo_id(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_AMMO_ID)
}
/// `PLAYER_SELF_RES_SPELL` — the spell the server will cast on `CMSG_SELF_RES`, i.e. **the
/// self-resurrect this corpse is owed**: a warlock's Soulstone ("Use Soulstone" 3026 /
/// 20758-20761), a shaman's Reincarnation (21169), or Twisting Nether (23700). `None` when
/// there is none — the field absent *or* zero are the same fact, so both collapse here
/// (decision 1746).
///
/// The server writes it at the death itself (`Player::SetDeathState(JUST_DIED)` keeps a
/// standing soulstone id across the aura wipe, else falls back to
/// `Player::SelectResurrectionSpellId()` for the passive cases) and **zeroes it on any
/// resurrection by another means** — so this field alone is the whole client-side condition;
/// there is no second gate and no cooldown/reagent test on our side. It is flushed
/// *immediately*, in its own `SMSG_UPDATE_OBJECT`, one line **before** the health→0 flush
/// (`Unit::DealDamage`, `Unit.cpp:1136-1143`) — which is why the field is already in the
/// descriptor by the time the death edge fires, and why the DEATH popup can read it in
/// `OnShow`.
///
/// `PLAYER_FLAGS_CAN_SELF_RESURRECT (0x1000)` is the pre-1.6 carrier of the same fact and is
/// dead on this build — vmangos sets it only under `SUPPORTED_CLIENT_BUILD < CLIENT_BUILD_1_6_1`
/// (`Player.cpp:1537-1569`).
pub fn player_self_res_spell(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_SELF_RES_SPELL)
.filter(|&s| s != 0)
}
/// `PLAYER_BYTES` — packed skinColor (byte 0) / faceType (byte 1) / hairStyle (byte 2) / hairColor
/// (byte 3). The character compositor's customization input (RF-0056).
pub fn player_bytes(&self) -> Option<u32> {
@@ -282,6 +282,27 @@ fn player_ammo_id_reads_the_field() {
assert_eq!(ObjectFields::default().player_ammo_id(), None);
}
#[test]
fn player_self_res_spell_collapses_absent_and_zero() {
// 1224 sits one past PLAYER_AMMO_ID(1223), which is one past PLAYER_FIELD_BYTES(1222) — the
// run this test pins by neighbour so a mis-derived index shows up as a wrong reading, not a
// silent None. A rank-1 soulstone's effect spell is 3026 (decision 1746).
let f = ObjectFields::from_pairs(&[(1222, 0), (1223, 0), (1224, 3026)]);
assert_eq!(f.player_self_res_spell(), Some(3026));
assert_eq!(
f.player_ammo_id(),
Some(0),
"the neighbour is not disturbed"
);
// "No self-res" reaches us two ways — the field never streamed, or the server zeroed it on a
// resurrection by another means — and they are the same fact, so both read None.
assert_eq!(ObjectFields::default().player_self_res_spell(), None);
assert_eq!(
ObjectFields::from_pairs(&[(1224, 0)]).player_self_res_spell(),
None
);
}
#[test]
fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() {
// flags=0x01 / combo=0x02 / actionBars=0x03 / highestHonorRank=5 packed little-endian: the
@@ -793,6 +793,13 @@ impl WorldSession {
self.send(opcode::MSG_CORPSE_QUERY, &[])
}
/// Self-resurrect (`CMSG_SELF_RES`, empty body) — the unsplit twin of
/// [`WorldWriter::self_res`], for the `--self-res` probe. The server casts whatever
/// `PLAYER_SELF_RES_SPELL` holds and zeroes the field.
pub fn self_res(&mut self) -> Result<()> {
self.send(opcode::CMSG_SELF_RES, &[])
}
/// Ask the graveyard's Spirit Healer for the immediate res (`CMSG_SPIRIT_HEALER_ACTIVATE`,
/// full guid) — the unsplit twin of [`WorldWriter::spirit_healer_activate`], for the
/// `--spirit` probe's live durability-loss verification. The server resurrects at 50% health
@@ -1,8 +1,9 @@
//! The death/corpse-run family's `WorldWriter` sends — release the spirit, find the corpse,
//! reclaim it, or take the spirit healer's res; plus the answer to someone else's res offer.
//! reclaim it, self-resurrect off a soulstone, or take the spirit healer's res; plus the answer
//! to someone else's res offer.
//! Bodies in [`crate::messages`]'s `reclaim_corpse`/`spirit_healer_activate`/`resurrect_response`
//! builders (the first two verbs are bodyless). Split out of `writer/mod.rs` (decision 0636),
//! mirroring [`crate::messages::death`].
//! builders (`repop_request`, `corpse_query` and `self_res` are bodyless). Split out of
//! `writer/mod.rs` (decision 0636), mirroring [`crate::messages::death`].
//!
//! Every one of these is server-gated on a death state the client only *believes* it is in
//! (ghost/unreleased/delay-elapsed/in-range), so a refusal is normal and is not always a packet —
@@ -30,6 +31,14 @@ impl WorldWriter {
self.send(opcode::MSG_CORPSE_QUERY, &[])
}
/// Self-resurrect (`CMSG_SELF_RES`, empty body — decision 1746): the DEATH popup's second
/// button, offered only while `PLAYER_SELF_RES_SPELL` is non-zero. The server casts that
/// spell on us and zeroes the field; like the reclaim, the success is ordinary descriptor
/// deltas (alive, health/mana per the spell) with no answer packet of its own.
pub fn self_res(&mut self) -> Result<()> {
self.send(opcode::CMSG_SELF_RES, &[])
}
/// Reclaim our corpse (`CMSG_RECLAIM_CORPSE` — the RECOVER_CORPSE popup's Accept): the corpse's
/// guid. Server gates: ghost, the reclaim delay elapsed, within 39 yd. Success comes back as
/// ordinary descriptor deltas (alive, ghost flags clear) + the corpse-to-bones swap.
@@ -89,6 +89,7 @@ mod skills;
mod social;
mod spells;
mod stable;
mod summon;
mod taxi;
mod trade;
mod trainer;
@@ -0,0 +1,27 @@
//! The summon answer — the client's one send in the summon family (decision 1747).
//!
//! There is no "summon me" verb and no decline verb: the flow starts with the *server's*
//! `SMSG_SUMMON_REQUEST` (a warlock's ritual, a meeting stone, a GM) and this is the Yes.
//! Declining is silence — the server auto-declines when its own two-minute window runs out — which
//! is why this file has exactly one function and its twin does not exist.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Accept a summon (`CMSG_SUMMON_RESPONSE`) — the `CONFIRM_SUMMON` dialog's Accept, carrying
/// the guid the question arrived with.
///
/// The server answers by teleporting us to the summon point it recorded when it asked. It does
/// **not** check the guid (vmangos reads it and calls `Player::SummonIfPossible(true)` off its
/// own `m_summon_expire`), so what makes a late accept harmless is that timer, not this field.
pub fn summon_response(&mut self, summoner_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_SUMMON_RESPONSE,
&messages::summon_response(summoner_guid),
)
}
}
+40 -6
View File
@@ -1,5 +1,6 @@
//! The death-arc Lua surface (decision 0308): the release/reclaim/resurrect verbs and the state
//! getters the reference's DEATH / RECOVER_CORPSE / RESURRECT* / XP_LOSS dialogs call.
//! The death-arc Lua surface (decisions 0308, 1746): the release/reclaim/resurrect/self-res verbs
//! and the state getters the reference's DEATH / RECOVER_CORPSE / RESURRECT* / XP_LOSS dialogs
//! call.
//!
//! Engine-free seam like [`super::unit`]: the app pushes a [`DeathUiState`] snapshot per frame
//! (the countdowns + offer bits it computes from the wire) and drains the queued
@@ -36,6 +37,21 @@ pub struct DeathUiState {
/// `None` below the sickness level (`GetResSicknessDuration` → nil — picks XP_LOSS vs
/// XP_LOSS_NO_SICKNESS, ref UIParent.lua's CONFIRM_XP_LOSS arm).
pub sickness_duration: Option<String>,
/// What `HasSoulstone()` answers: the **label** of the self-resurrect available right now, or
/// `None` for nil (decision 1746, whose Context carries the wow-re decode this shape comes from).
///
/// A string, not an id, because that is the whole of what the API returns: the DEATH dialog
/// stamps it straight onto its second button (`Button2:SetText(HasSoulstone())`) and uses the
/// same call as `DisplayButton2`. The app resolves it, because the script VM has no
/// spell-catalog or item-cache binding (the `ui_cast`/`ui_mirror` idiom, decision 0107).
///
/// Two sources, and the client's own fork picks between them
/// ([`crate::script::death`]'s app-side resolver): `PLAYER_SELF_RES_SPELL` named through
/// `Spell.dbc` (3026/20758-20761 are all literally **"Use Soulstone"**, 21169
/// **"Reincarnation"**, 23700 **"Twisting Nether"** — Blizzard named those effect spells *as
/// button labels*), else a carried item whose on-use spell self-resurrects, named by the
/// **item**.
pub self_res_label: Option<String>,
}
/// One drained death intent (the app's [`super::UiScript::take_death_actions`] maps each to its
@@ -52,6 +68,11 @@ pub enum DeathAction {
DeclineResurrect,
/// `AcceptXPLoss()` — take the spirit healer's res (`CMSG_SPIRIT_HEALER_ACTIVATE`).
AcceptXpLoss,
/// `UseSoulstone()` — spend the self-resurrect. **Which wire that is, is the app's to decide
/// at drain time**, exactly as the binding decides it at call time: a non-zero
/// `PLAYER_SELF_RES_SPELL` sends `CMSG_SELF_RES`, and a zero one falls through to using the
/// carried item instead (decision 1746).
UseSoulstone,
}
impl super::UiScript {
@@ -84,6 +105,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_action(lua, "AcceptResurrect", DeathAction::AcceptResurrect)?;
install_action(lua, "DeclineResurrect", DeathAction::DeclineResurrect)?;
install_action(lua, "AcceptXPLoss", DeathAction::AcceptXpLoss)?;
install_action(lua, "UseSoulstone", DeathAction::UseSoulstone)?;
// GetReleaseTimeRemaining() → seconds until the forced release, or −1 = no timer (the DEATH
// dialog's no-timer text pick, ref StaticPopup.lua:380-387).
@@ -142,10 +164,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// HasSoulstone() → nil: soulstones are a named 0308 deferral, and nil is exactly the real
// return for a player with no active soulstone — the DEATH dialog's button2 stays hidden and
// its OnCancel falls through to RepopMe(), both ref-faithful.
g.set("HasSoulstone", lua.create_function(|_, ()| Ok(Value::Nil))?)?;
// HasSoulstone() → the self-resurrect's label, or nil (decision 1746). The DEATH dialog
// uses the one call three ways — `DisplayButton2` (show the button at all), `OnShow`
// (`Button2:SetText(text)`) and `OnCancel`'s clicked arm (soulstone vs release) — so the
// falsey return has to be **nil** and not `0`: Lua's `0` is truthy, and a `0` here would both
// show the button forever and stamp "0" on it. The whole answer is computed app-side; this is
// a pure read of the pushed snapshot.
g.set(
"HasSoulstone",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match &model.death.self_res_label {
Some(s) => Value::String(lua.create_string(s)?),
None => Value::Nil,
})
})?,
)?;
Ok(())
}
+3
View File
@@ -123,6 +123,7 @@ mod spellbook;
mod stable;
mod statusbar;
mod stdlib;
mod summon;
mod talent;
mod taxi;
mod tick;
@@ -216,6 +217,7 @@ pub use spellbook::{
resolve_spell_by_name, PetBookState, SpellBookState, SpellSlotView, SpellTabView,
};
pub use stable::{StableIntent, StablePetSlot, StableState, NUM_STABLE_SLOTS};
pub use summon::SummonConfirmUiState;
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
pub use taxi::{TaxiNodeType, TaxiUiNode, TaxiUiState};
pub use tooltip_spell::SpellTooltipView;
@@ -558,6 +560,7 @@ impl UiScript {
guild::install(&lua)?;
petition::install(&lua)?;
binder::install(&lua)?;
summon::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
+12
View File
@@ -1388,6 +1388,16 @@ pub(crate) struct Model {
/// CONFIRM_BINDER dialog polls it from OnUpdate and hides itself when it goes false, which is
/// how walking away from the innkeeper takes the question off screen (decision 1331).
pub(crate) binder_pending: bool,
/// The summon question's three resolved reads, pushed by the app each frame
/// ([`super::UiScript::set_summon_confirm`]) — what `GetSummonConfirmSummoner`,
/// `GetSummonConfirmAreaName` and `GetSummonConfirmTimeLeft` answer ([`super::summon`]).
/// Default (two empty strings and a zero) is exactly what the reference's getters answer with
/// nothing pending, so the pre-push window behaves like the post-expiry one (decision 1747).
pub(crate) summon_confirm: super::summon::SummonConfirmUiState,
/// `ConfirmSummon()` calls queued since the app's last drain — each is one outbound
/// `CMSG_SUMMON_RESPONSE`. A COUNT for [`Self::binder_confirms`]'s reason: the app holds the
/// summoner's guid, so the intent carries no payload of its own.
pub(crate) summon_confirms: u32,
/// Frames per second, behind `GetFramerate()`. Pushed per tick by the app, which owns the
/// clock this crate does not have.
pub(crate) framerate: f64,
@@ -1731,6 +1741,8 @@ impl Model {
stuck_casts: 0,
binder_confirms: 0,
binder_pending: false,
summon_confirm: super::summon::SummonConfirmUiState::default(),
summon_confirms: 0,
framerate: 0.0,
modifiers: (false, false, false),
money: 0,
+113
View File
@@ -0,0 +1,113 @@
//! The player-summon **Era API surface** (decision 1747) — four globals, one snapshot.
//!
//! These are the whole Lua side of being summoned, and they exist because the reference's
//! `CONFIRM_SUMMON` dialog is written against exactly them (`StaticPopup.lua:1336-1357` +
//! `UIParent.lua:551-552`): the `CONFIRM_SUMMON` event raises the dialog with **no arguments at
//! all**, `OnShow` seeds its countdown from [`GetSummonConfirmTimeLeft`], the popup engine's
//! per-tick text re-reads [`GetSummonConfirmSummoner`] and [`GetSummonConfirmAreaName`], and
//! `OnAccept` calls [`ConfirmSummon`]. `reference/1.12-globals.tsv` lists all four as
//! `function`/`engine`, and there is **no fifth**: 1.12 has no `CancelSummon`, because declining
//! sends no packet.
//!
//! **Why the three getters read a pushed snapshot rather than arguments.** The reference keeps the
//! request in four engine globals — `[0xb4e358]/[0xb4e35c]` the summoner guid, `[0xb4e354]` the
//! zone id, `[0xb4e350]` the deadline — and each getter resolves *at call time*: the summoner
//! through the name cache (`DBCache::NameCache::GetRecord 0x55f080`, `""` on a miss), the area
//! through `AreaTable.dbc` (`""` when the row is missing), the time left as
//! `(deadline − now) / 1000`. Both resolves need host state this crate does not have, so the app
//! does them and pushes the answers every frame ([`super::UiScript::set_summon_confirm`]). The
//! observable is identical, including the one that matters: a summoner whose name has not arrived
//! yet renders `""` and then fills itself in on a later tick, because the dialog re-reads the
//! getter every OnUpdate.
//!
//! `ConfirmSummon()` takes no arguments and queues only a count, [`super::binder`]'s shape and for
//! its reason: the app holds the guid that goes on the wire.
use mlua::Lua;
use super::Model;
/// The `CONFIRM_SUMMON` dialog's three reads, resolved by the app and pushed each frame.
///
/// All three are the *reference's* fallbacks when nothing is pending or nothing resolves — two
/// empty strings and a zero — because every one of its getters pushes a value on every path
/// (`""` is the shared static `0x882748`; the time-left reader returns 0 for a zeroed deadline).
/// Nothing here is ever nil, which is what lets the dialog's `format` run unguarded.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SummonConfirmUiState {
/// The summoner's resolved name, or `""` while the name query is still out.
pub summoner: String,
/// The summoner's zone name out of `AreaTable.dbc`, or `""` if the id names no row.
pub area: String,
/// Milliseconds left before the offer expires — `0` when nothing is pending or the window has
/// already run out. **Milliseconds, not seconds**, because the reference's truncation to whole
/// seconds happens inside the binding (`0x48b660`'s `/1000`), and that is where it happens
/// here too: the floor is part of the API's contract, not part of the host's bookkeeping.
pub time_left_ms: u32,
}
impl super::UiScript {
/// Push the summon question's three resolved reads (the app's per-frame feed).
pub fn set_summon_confirm(&mut self, state: SummonConfirmUiState) {
let mut model = self.model_mut();
if model.summon_confirm != state {
model.summon_confirm = state;
}
}
/// Drain the `ConfirmSummon()` calls queued since the last drain — each one is a
/// `CMSG_SUMMON_RESPONSE`. [`Self::take_binder_confirms`]'s shape, and a count for the same
/// reason: the intent has no payload of its own.
pub fn take_summon_confirms(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().summon_confirms)
}
}
/// Register the four summon globals (the style [`super::binder`] registers its two).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetSummonConfirmSummoner() — who is summoning you (`0x48b6a0`). A string on every path.
g.set(
"GetSummonConfirmSummoner",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
lua.create_string(&model.summon_confirm.summoner)
})?,
)?;
// GetSummonConfirmAreaName() — where to (`0x48b720`). The **summoner's** zone, straight out of
// `AreaTable.dbc` with no parent walk and no GlobalString tail: unlike the innkeeper question's
// three-step chain (`0x5dfe5e`), an id this table cannot name renders as `""`.
g.set(
"GetSummonConfirmAreaName",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
lua.create_string(&model.summon_confirm.area)
})?,
)?;
// GetSummonConfirmTimeLeft() — whole SECONDS left, truncated (`0x48b660`: the `0x10624dd3`
// magic multiply is an integer `/1000` over the millisecond remainder, then `lua_pushnumber`).
// The dialog seeds `this.timeleft` from it in OnShow and the popup engine counts down locally
// from there, so the floor lands once, at open.
g.set(
"GetSummonConfirmTimeLeft",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(f64::from(model.summon_confirm.time_left_ms / 1000))
})?,
)?;
// ConfirmSummon() — the CONFIRM_SUMMON dialog's Accept, and the only packet in the whole flow.
g.set(
"ConfirmSummon",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.summon_confirms += 1;
Ok(())
})?,
)?;
Ok(())
}