Files
benilla-octo/crates/benilla-ui/src/script/summon.rs
T
SamandClaude Fable 5 ddc9e1a5a3 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>
2026-08-31 20:16:13 +07:00

114 lines
5.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}