- 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>
114 lines
5.6 KiB
Rust
114 lines
5.6 KiB
Rust
//! 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(())
|
||
}
|