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:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user