Files
benilla-octo/crates/benilla-app/src/death.rs
T
SamandClaude Fable 5 25c865c00a the ghost world gets its own sky, and a corpse cannot steer
- a dead player's sky is DeathClouds now — `lightSkyboxID` was named in the schema and read by nobody and `LightSkybox.dbc` was never loaded at all, so a ghost kept the ordinary gradient dome; the shipped data has exactly 5 of 426 LightParams rows carrying a skybox id, every one of them DeathClouds.mdx, reached from the death profile (slot 4) of all 374 Light rows and gated on the ghost bit, with a hardcoded weight of 1.0 that skips the WMO skybox outright; it resolves off the same (map, camera position) seed the atmosphere uses, so the ghost sky and the death-profile fog it hangs over switch on the same frame — and the lane sheds its `wmo_sky` name for `skybox`, no longer being WMO-only
- a corpse cannot steer: the reference's two movement-input predicates share a precondition benilla never modelled — `UNIT_FIELD_HEALTH > 0` — so at zero health the keyboard turn and the right-drag close together, exactly as they do inside an Ice Block, while the ghost (health 1) keeps moving; the wow-re §5 round then corrected three byte trails (the mouse turn has its own eight-conjunct predicate, the supposed on-taxi test is the Knockdown animation lockout — set on exactly one of 208 AnimationData rows — and the "charm bit" is the far-sight latch) and added three verified health gates we lacked: the jump IS health-gated, sit and stand are refused while the body reads dead (a feigner too), and a follow tears down only when BOTH predicates drop — we had that wrong in both directions
- the walk-gait cross-check folds back confirming `min(walk, run)` and the speed-only animation channel (by a 2,304-function closure census), and finds the trap we lacked: every incoming spline re-authors the gait, and the spline's RUNMODE flag is an INVERSE — a spline marked as a run clears walk mode, one not so marked SETS it — so a charge, taxi, knockback or flee hand-off leaves the body in whatever gait its path was authored with, applied once on the spline's start edge; the post-worldport zero-walk fallback is retracted on a re-run (the ctor's zero is a transient thirty instructions wide, never observable), leaving the plain minimum the bytes compute — the better failure mode too, since a fallback makes a walker silently RUN if a walk speed ever goes missing, where the transcription makes them visibly stop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:18:16 +07:00

906 lines
43 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 death arc (decision 0308): the wire-fed stores, the state-machine edges into the Lua UI,
//! the intent drain back to the wire, and the root/water-walk messages the controller acks.
//!
//! The state machine is derived, never simulated (0308 §1): dead = health 0, ghost =
//! `PLAYER_FLAGS` bit 0x10 — read off the self descriptor each frame, edges fired as the
//! reference's own events (`PLAYER_DEAD` / `PLAYER_ALIVE` / `PLAYER_UNGHOST`, the classic
//! semantics: ALIVE fires on release *and* on a pre-release res; UNGHOST on ghost → alive). What
//! lives in [`DeathNet`] is only what the descriptor does not carry: the reclaim-delay expiry
//! (`SMSG_CORPSE_RECLAIM_DELAY`), the corpse location (`MSG_CORPSE_QUERY`), a pending resurrect
//! offer (`SMSG_RESURRECT_REQUEST`), and the spirit-healer confirm (`SMSG_SPIRIT_HEALER_CONFIRM`).
use bevy::prelude::*;
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.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct CorpsePoint {
/// The map to show/route toward — dungeon-entrance-adjusted server-side (a corpse inside an
/// instance points at its `ghostEntranceMap` position instead).
pub(crate) display_map: i32,
/// Raw WoW position of the corpse (or the dungeon entrance standing in for it).
pub(crate) position: [f32; 3],
/// The corpse's REAL map id, never adjusted — the marker shows only while we're on this map
/// (or on `display_map` for the entrance stand-in).
#[allow(dead_code)] // read by the slice-2 map markers
pub(crate) corpse_map: u32,
}
/// A pending resurrection offer (`SMSG_RESURRECT_REQUEST`) — the RESURRECT popup's data.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResurrectOffer {
/// The offerer. The popup answers `CMSG_RESURRECT_RESPONSE` with this guid.
pub(crate) caster: u64,
/// The offerer's display name — EMPTY on the wire for a player caster (the feed resolves it
/// through the guid name cache, ask-once, like every other player name).
pub(crate) name: String,
/// Accepting will apply resurrection sickness (picks the RESURRECT popup variant).
pub(crate) sickness: bool,
/// The client should still honor the reclaim-delay gate (RESURRECT vs RESURRECT_NO_TIMER).
pub(crate) has_timer: bool,
}
/// The wire-fed death state (decision 0308 §1's death feed). Filled by the net drain; read by the
/// death UI feed and the corpse-run systems. Cleared on session teardown with the rest of the
/// net-owned resources.
#[derive(Resource, Default)]
pub(crate) struct DeathNet {
/// `Time::elapsed_secs_f64` when the corpse becomes reclaimable — `SMSG_CORPSE_RECLAIM_DELAY`'s
/// delay anchored to arrival time (the packet lands at release and at login-while-dead).
/// `GetCorpseRecoveryDelay` reads `max(0, reclaim_at − now)`; `None` before any delay landed.
pub(crate) reclaim_at: Option<f64>,
/// Bumped on every `SMSG_CORPSE_RECLAIM_DELAY` — the feed's range latch re-announces on it
/// (the client's 0x269 handler re-fires IN_RANGE/IN_INSTANCE through its latch).
pub(crate) reclaim_generation: u32,
/// The corpse location, from the last `MSG_CORPSE_QUERY` answer. `None` = no corpse (also the
/// server's unprompted not-found at bones-conversion — "drop the marker").
pub(crate) corpse: Option<CorpsePoint>,
/// A pending resurrection offer; cleared when answered or when the popup times out.
pub(crate) resurrect: Option<ResurrectOffer>,
/// Our streamed corpse OBJECT's guid (a TYPEID_CORPSE create whose `CORPSE_FIELD_OWNER` is
/// us) — what `CMSG_RECLAIM_CORPSE` carries, like the real client. `None` until the corpse
/// streams into range (vmangos ignores the guid's content, so a 0 send still reclaims —
/// the real-client shape is kept where we have it).
pub(crate) corpse_guid: Option<u64>,
/// The spirit healer whose confirm (`SMSG_SPIRIT_HEALER_CONFIRM`) is awaiting the XP_LOSS
/// two-step; its Accept sends `CMSG_SPIRIT_HEALER_ACTIVATE` with this guid.
pub(crate) spirit_healer: Option<u64>,
/// Bumped on every `SMSG_SPIRIT_HEALER_CONFIRM` — the confirm announces per MESSAGE, not per
/// Some-edge (decision 1068): a Cancel sends nothing and clears nothing, so only a fresh
/// message can re-show the dialog, and the healer re-sends one on every gossip ask.
pub(crate) confirm_generation: u32,
/// Water-walking is granted on our mover (the ghost's walk-on-water). Mirrored for the swim
/// arc's future mover regime (0308 defers the actual water-surface walk); the ack itself is
/// the controller's.
#[allow(dead_code)]
pub(crate) water_walk: bool,
}
/// The feed's memory, in **two scopes** (decisions 1290/1291): the body's own state machine is
/// world memory and survives a `/reload`; what this VM has been *told* dies with the VM.
///
/// The reference draws the same line by construction — its death mirror is engine-side (wow-re
/// death-ui.md §1), so a `ReloadUI` neither re-arms the release window nor loses it, while the
/// rebuilt UI repaints from the surviving state. Ours mirrors that: `mirror`/`died_at` stay put
/// across a VM replacement, and the announce latches reset with the VM so the fresh frame tree
/// hears the events the old one consumed.
#[derive(Resource, Default)]
struct DeathFeedState {
/// **World-scoped.** The last `(guid, dead, ghost)` the self body showed — the edge that
/// arms and clears the release window. `None` before a world session's first snapshot.
mirror: Option<(u64, bool, bool)>,
/// **World-scoped.** `Time::elapsed_secs_f64` at the death edge — the anchor of the
/// client-side release window (byte-VERIFIED, wow-re death-ui.md §1: the wire carries only
/// the PLAYER_FIELD_BYTES timer BIT; the client arms `now + 360000 ms` at its own
/// alive→dead mirror-edge — so a login-while-dead re-arms at login exactly like ours, and
/// expiry reads 0, never negative). Deliberately NOT per-VM: a `/reload` must not restart
/// the six minutes — the reference's engine-side window keeps running through one.
died_at: Option<f64>,
/// **VM-scoped** — the announce latches, keyed on the VM they announced to.
vm: crate::ui_script::VmMemo<DeathAnnounced>,
}
/// What the live VM has been told about the death state — [`DeathFeedState::vm`]'s payload
/// (guid-keyed, first-snapshot-counts-as-edge so logging in dead brings the popup up — and so
/// does reloading dead, which is the same situation from the frame tree's point of view).
#[derive(Default)]
struct DeathAnnounced {
/// The last `(guid, dead, ghost)` THIS VM was given an event for; `None` for a fresh VM.
last: Option<(u64, bool, bool)>,
/// The last corpse-range verdict announced to the UI (`None` = nothing announced): the
/// CORPSE_IN_RANGE / CORPSE_OUT_OF_RANGE / CORPSE_IN_INSTANCE edge memory (decision 0308 §5).
corpse_range: Option<CorpseRange>,
/// The [`DeathNet::reclaim_generation`] this latch last saw — a fresh `0x269` re-fires the
/// range events through the latch (the client's own re-announce, death-ui.md §4), so the
/// RECOVER_CORPSE popup re-shows with the new delay.
reclaim_generation: u32,
/// The pending resurrect offer has been announced to the UI (`RESURRECT_REQUEST` fired) —
/// held back while a player-caster's name is still resolving through the name cache.
offer_announced: bool,
/// The [`DeathNet::confirm_generation`] this latch last announced — a fresh
/// `SMSG_SPIRIT_HEALER_CONFIRM` re-fires `CONFIRM_XP_LOSS` through the latch (decision 1068;
/// the reclaim-latch pattern above). The old Some-edge latch here was B80's deadlock: it
/// reset only when `spirit_healer` went `None`, which nothing but Accept ever does, so one
/// Cancel swallowed every later confirm.
confirm_generation: u32,
}
/// The three corpse-range verdicts the reference's events distinguish (RECOVER_CORPSE /
/// hide / RECOVER_CORPSE_INSTANCE).
#[derive(Clone, Copy, PartialEq, Eq)]
enum CorpseRange {
Out,
In,
InInstance,
}
/// The server's forced-release window (vmangos `CORPSE_REPOP_TIME` = 6 min) — the client-side
/// mirror both sides know; never on the wire.
const RELEASE_WINDOW_SECS: f64 = 360.0;
/// The corpse-range dialog radius — byte-VERIFIED **40.0 yd, inclusive** (wow-re death-ui.md §3:
/// the client compares d² ≤ `[0xb4e2ac]` = 1600.0 per frame against the corpse-QUERY cache, never
/// the streamed corpse object; edge-latched, no hysteresis). vmangos's `CORPSE_RECLAIM_RADIUS 39`
/// comment "equal client dialog show radius" is off by one against the client's own constant —
/// the server reclaim gate simply sits 1 yd inside the dialog radius. Squared yards.
const CORPSE_RANGE_SQ: f32 = 40.0 * 40.0;
/// The spirit-healer dialog range — byte-VERIFIED (wow-re death-ui.md §7: CheckSpiritHealerDist
/// compares d² ≤ `[0xc4c28c]` = 30.864 = 5.5556², the service interaction range). Squared yards.
const SPIRIT_HEALER_RANGE_SQ: f32 = 5.5556 * 5.5556;
/// Per-frame: derive the death state from the self descriptor, push the countdown/offer snapshot,
/// and fire the reference's death events on the edges (before `UiInput`, so a frame's `OnEvent`
/// sees current values — the [`crate::ui_unit`] feed convention).
#[allow(clippy::too_many_arguments)]
fn feed_death(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
self_guid: Res<SelfGuid>,
death_net: Res<DeathNet>,
// Real, not virtual: this reads a stamp the net apply path wrote on `Time<Real>` (see the
// aura tuple there) — a server-sent countdown lives in real seconds, and the two ends of the
// comparison must be the same clock.
time: Res<Time<Real>>,
mut feed: ResMut<DeathFeedState>,
mut names: ResMut<crate::names::NameCache>,
net: Res<NetCommands>,
index: Res<GuidIndex>,
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
// without this gate the frames between the socket dying and the reconnect landing would
// re-arm `mirror` from a frozen relic, and the reconnect would then find no edge to fire.
// That is the same hole from the other side as [`end_session_death_feed`]: the body's state
// machine may only be driven by the wire, and while there is no wire there is nothing to
// mirror. (The `/logout` path needs no gate — the avatar is despawned, so the `self_q` below
// already ends the feed.)
if !status.connected {
return;
}
let Some(mut script) = script else {
return;
};
let Some(guid) = self_guid.0 else {
return;
};
let Ok((store, self_t)) = self_q.single() else {
return;
};
let now = time.elapsed_secs_f64();
let dead = store.0.unit_is_dead();
let ghost = store.0.player_is_ghost();
// ── The snapshot (set before the events fire, so their handlers read current values) ──────
let release_remaining = if store.0.player_release_timer_running() {
feed.died_at
.map(|t| ((t + RELEASE_WINDOW_SECS - now).max(0.0)) as f32)
} else {
None // → GetReleaseTimeRemaining() == −1, the no-timer DEATH text
};
let recovery_delay = death_net
.reclaim_at
.map_or(0.0, |at| (at - now).max(0.0) as f32);
let spirit_healer_in_range = death_net.spirit_healer.is_some_and(|npc| {
index
.0
.get(&npc)
.and_then(|&e| transforms.get(e).ok())
.is_some_and(|t| {
t.translation.distance_squared(self_t.translation) <= SPIRIT_HEALER_RANGE_SQ
})
});
script.set_death(DeathUiState {
release_remaining,
recovery_delay,
resurrect_sickness: death_net.resurrect.as_ref().is_some_and(|o| o.sickness),
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
// corpse asks. These run off `mirror`, which survives a VM replacement — a `/reload` while
// dead must neither restart the six minutes nor re-query the corpse (the cache has it).
let prev = feed.mirror.filter(|&(g, ..)| g == guid);
feed.mirror = Some((guid, dead, ghost));
match (prev, dead, ghost) {
// Alive → dead (and the login-while-dead first snapshot): arm the release window.
(Some((_, false, false)) | None, true, false) => {
feed.died_at = Some(now);
}
// Dead-unreleased → ghost (the release landed), or → alive (a pre-release res).
(Some((_, true, false)), d, g) if g || !d => {
feed.died_at = None;
if g {
// Now a ghost: ask where the corpse is (feeds the slice-2 markers + range gate).
let _ = net.0.send(ClientCommand::CorpseQuery);
}
}
// Ghost → alive (reclaim / spirit healer / accepted res).
(Some((_, _, true)), false, false) => {
feed.died_at = None;
// Re-ask where the corpse is — the answer is the authoritative NOT-FOUND that drops
// the map markers. The server's own "corpse gone" push is LOOTER-gated (vmangos
// Map.cpp:3617-3629: the unprompted u8(0) goes out only when a PvP looter exists), so
// a PvE res never gets one; the real client re-runs its corpse query off the same
// ghost-bit edge (wow-re death-ui.md §5's watcher), and post-res the player→corpse
// binding is gone (SpawnCorpseBones unbinds), so the answer clears the cache.
// Director-reported: the map tombstone survived a spirit-healer res without this.
let _ = net.0.send(ClientCommand::CorpseQuery);
}
// Logging in already a ghost: no edge, just the corpse ask.
(None, _, true) => {
let _ = net.0.send(ClientCommand::CorpseQuery);
}
_ => {}
}
// ── The VM edges: the classic events, fired off what THIS VM has heard (module doc). A
// fresh VM's memo is empty, so a reload-while-dead re-fires PLAYER_DEAD to the rebuilt
// frame tree — the same first-snapshot-counts-as-edge that brings the popup up at a
// login-while-dead, which is our design's own posture for both (decision 1291).
let memo = feed.vm.get(&script);
let prev = memo.last.filter(|&(g, ..)| g == guid);
memo.last = Some((guid, dead, ghost));
match (prev, dead, ghost) {
(Some((_, false, false)) | None, true, false) => {
script.fire_event("PLAYER_DEAD", vec![]);
}
(Some((_, true, false)), d, g) if g || !d => {
script.fire_event("PLAYER_ALIVE", vec![]);
}
(Some((_, _, true)), false, false) => {
script.fire_event("PLAYER_UNGHOST", vec![]);
}
_ => {}
}
// ── The offer/confirm announcements (edge-fired, name-gated) ───────────────────────────────
match &death_net.resurrect {
Some(offer) if !memo.offer_announced => {
// A player caster's wire name is empty — resolve through the ask-once name cache and
// hold the popup until it lands (the ref popup formats "%s wants to resurrect you").
let name = if offer.name.is_empty() {
names.resolve(offer.caster, &net).map(str::to_owned)
} else {
Some(offer.name.clone())
};
if let Some(name) = name {
memo.offer_announced = true;
script.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str(name)]);
}
}
None => memo.offer_announced = false,
_ => {}
}
// The confirm is message-fired, not state-edged (decision 1068): each SMSG bumps the
// generation, so asking the healer again after a Cancel brings the dialog back.
if death_net.spirit_healer.is_some() && memo.confirm_generation != death_net.confirm_generation
{
memo.confirm_generation = death_net.confirm_generation;
script.fire_event("CONFIRM_XP_LOSS", vec![]);
}
// ── The corpse-run range gate (0308 §5): fires the reference's range events on the edges. ──
// Only a ghost runs its corpse; the verdict compares 3-D distance to the query's DISPLAY
// position (for a dungeon corpse that's the entrance — standing there with the corpse on
// another map is exactly the CORPSE_IN_INSTANCE case). Distances are isometric across the
// coord transform, so the Bevy-space compare is the yard compare.
let range = if ghost {
death_net.corpse.map(|cp| {
// The client's own law (death-ui.md §3-4): within 40 yd (inclusive) of the QUERY
// cache position, on the display map → IN_RANGE when displayMapId == corpseMapId,
// else IN_INSTANCE (standing at a dungeon's ghost entrance, corpse inside).
let on_display_map = map
.as_ref()
.is_some_and(|m| m.0 == u32::try_from(cp.display_map).unwrap_or(u32::MAX));
let near = on_display_map
&& wow_to_bevy(cp.position).distance_squared(self_t.translation) <= CORPSE_RANGE_SQ;
let same_map = u32::try_from(cp.display_map) == Ok(cp.corpse_map);
match (near, same_map) {
(false, _) => CorpseRange::Out,
(true, true) => CorpseRange::In,
(true, false) => CorpseRange::InInstance,
}
})
} else {
None
};
if memo.reclaim_generation != death_net.reclaim_generation {
memo.reclaim_generation = death_net.reclaim_generation;
memo.corpse_range = None; // re-announce whatever holds now (the client's 0x269 re-fire)
}
if range != memo.corpse_range {
match range {
Some(CorpseRange::In) => script.fire_event("CORPSE_IN_RANGE", vec![]),
Some(CorpseRange::InInstance) => script.fire_event("CORPSE_IN_INSTANCE", vec![]),
// Leaving range — and the corpse/ghost state ENDING while a range dialog could be
// up — both land on the same hide event (the ref's OUT arm hides all three).
Some(CorpseRange::Out) | None if memo.corpse_range.is_some() => {
script.fire_event("CORPSE_OUT_OF_RANGE", vec![]);
}
_ => {}
}
memo.corpse_range = range;
}
}
/// `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.
fn sickness_duration(level: u32) -> Option<String> {
match level {
0..=10 => None,
11..=19 => {
let m = level - 10;
Some(if m == 1 {
"1 minute".into()
} else {
format!("{m} minutes")
})
}
_ => Some("10 minutes".into()),
}
}
/// Drive the ghost-world look (decision 0308 §7, all byte-VERIFIED): the FFXDeath screen pass
/// gates on `PLAYER_FLAGS_GHOST` — instant on release, instant off at resurrect (wow-re
/// death-pass.md: the flag's CMirrorHandler watcher is the single driver of the FFX pass swap,
/// the death light profile, and the ghost ambience — one watcher, several consumers; ours splits
/// the consumers across their owning systems off the same derived flag).
fn drive_death_look(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut ffx: ResMut<benilla_world::ffx_glow::FfxDeathFade>,
) {
let ghost = ghost_probe()
.unwrap_or_else(|| self_q.single().is_ok_and(|store| store.0.player_is_ghost()));
let target = if ghost { 1.0 } else { 0.0 };
if ffx.0 != target {
ffx.0 = target;
}
}
/// `WOW_GHOST_PROBE=1|0` — pin the **ghost world** on or off without dying, so the ghost-world look
/// (and everything that must NOT inherit it — the portrait bakes, decision 1481) can be A/B'd from a
/// capture instead of from a corpse run.
///
/// It drives all three halves of that look together, because they are one state: this screen pass,
/// the death light (`LightParams` slot 4) and the DeathClouds sky, the latter two through
/// [`Viewer::ghost`](benilla_world::view::Viewer::ghost), whose only readers they are. It pinned
/// the screen pass alone until the sky landed, which made an A/B of "the ghost world" quietly a
/// third of one. The death arc's state machine, its events and its UI stay untouched and still
/// follow the wire.
pub(crate) fn ghost_probe() -> Option<bool> {
static PROBE: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
*PROBE.get_or_init(|| match std::env::var("WOW_GHOST_PROBE").ok()?.trim() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
})
}
/// Per-frame (after `UiInput`, so this frame's clicks drain this frame): map the queued Lua death
/// intents onto the wire.
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,
// The soulstone leg is a real arm-290 caller in the reference's own census (wow-re
// `bind-confirm-law.md`: 290 ← UseSoulstone · UseInventoryItem · UseAction · UseContainerItem),
// so it goes through the shared send carrying the same bind gate every other item use does.
mut gate: crate::ui_bind_confirm::BindGate,
) {
let Some(mut script) = script else {
return;
};
for action in script.take_death_actions() {
let _ = match action {
DeathAction::Repop => net.0.send(ClientCommand::RepopRequest),
// The corpse guid: the streamed corpse object's, when it reached view range (the
// real client's shape); else 0 — vmangos never reads the content (it resolves the
// corpse via the player, MiscHandler.cpp:573-603), and inside 39 yd the object has
// long streamed anyway.
DeathAction::RetrieveCorpse => net.0.send(ClientCommand::ReclaimCorpse {
corpse: death_net.corpse_guid.unwrap_or(0),
}),
DeathAction::AcceptResurrect | DeathAction::DeclineResurrect => {
let accept = action == DeathAction::AcceptResurrect;
match death_net.resurrect.take() {
Some(offer) => net.0.send(ClientCommand::ResurrectResponse {
caster: offer.caster,
accept,
}),
None => Ok(()), // stale click after the offer timed out — nothing to answer
}
}
DeathAction::AcceptXpLoss => match death_net.spirit_healer.take() {
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,
&mut script,
&mut gate,
false,
);
Ok(())
}
// Nothing to spend — the reference's silent return (it pushes nothing and
// sends nothing on this path either).
None => Ok(()),
}
}
};
}
}
/// **The world scope of [`DeathFeedState`] dies with the world session** (decision 1732) — the
/// teardown [`DeathNet`]'s own has had since 0065, which this half never got.
///
/// `mirror` is the body's state machine and `died_at` its release-window anchor; both are
/// deliberately not per-VM, so a `/reload` cannot restart the six minutes. But "not per-VM" was
/// implemented as "never cleared", and the two scopes then disagreed across a relog: the session
/// teardown resets `DeathNet` (corpse marker included) while `mirror` still read
/// `(same guid, dead, ghost)` from before the socket died. Re-entering the world as a ghost
/// therefore matched **no** arm of the world-edge machine — `(None, _, true)`, the "logging in
/// already a ghost" arm, needs `prev == None` — so `MSG_CORPSE_QUERY` was never re-sent, the
/// corpse cache stayed empty, the range gate could not fire `CORPSE_IN_RANGE`, and the
/// **RECOVER_CORPSE ("Resurrect Now") popup never came up again after a relog**
/// (director-reported, 2026-08-30).
///
/// The guid filter on `mirror` looked like it covered this and does not: it distinguishes a
/// *different* character, not the *same* one re-entering the world. `DisconnectedMessage` is the
/// one total edge — a socket death, a kick, and a `/logout` all reach it (the IO thread emits a
/// `Disconnected` behind `SessionEnd::LoggedOut`) — and it is the very edge `DeathNet` is reset
/// on, which is what keeps the two scopes in lockstep by construction rather than by memory.
fn end_session_death_feed(
mut msgs: MessageReader<crate::net::DisconnectedMessage>,
mut feed: ResMut<DeathFeedState>,
) {
if msgs.read().next().is_some() {
*feed = DeathFeedState::default();
}
}
/// The death arc's app plugin (decision 0308): the net-fed stores, the state-machine feed + the
/// intent drain, and the controller-facing root/water-walk messages.
pub(crate) struct DeathPlugin;
impl Plugin for DeathPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<DeathNet>()
.init_resource::<DeathFeedState>()
.add_systems(
Update,
(
feed_death.before(UiInput),
drain_death.after(UiInput),
drive_death_look,
// Before the feed, so the frame a session ends is already a frame the feed
// sees no memory of the last one.
end_session_death_feed.before(feed_death),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
/// **The world scope dies with the session, and only with the session** (decision 1732).
///
/// Both halves matter and they pull opposite ways: a `/reload` must NOT restart the six
/// minutes (which is why `mirror`/`died_at` are not per-VM in the first place), and a relog
/// MUST clear them (or the "logging in already a ghost" arm never fires, no
/// `MSG_CORPSE_QUERY` goes out, and the RECOVER_CORPSE popup stays down — the reported bug).
/// A test that only asserted the clear would pass on a resource wiped every frame.
#[test]
fn the_session_edge_clears_the_world_scoped_death_memory_and_nothing_else_does() {
let mut app = App::new();
app.add_message::<crate::net::DisconnectedMessage>()
.insert_resource(DeathFeedState {
mirror: Some((0x1234, true, true)),
died_at: Some(12.0),
vm: crate::ui_script::VmMemo::default(),
});
// No session edge — the memory stands (the `/reload` half).
app.world_mut()
.run_system_once(end_session_death_feed)
.expect("the teardown runs");
assert_eq!(
app.world().resource::<DeathFeedState>().mirror,
Some((0x1234, true, true)),
"nothing but the session edge may clear the body's own state machine"
);
// A `/logout` is a session edge too — `session_over` is false for it, and the clear may
// not depend on that (a relog is exactly the `false` case).
app.world_mut()
.write_message(crate::net::DisconnectedMessage::new(
"logged out".into(),
benilla_protocol::SessionEnd::LoggedOut,
));
app.world_mut()
.run_system_once(end_session_death_feed)
.expect("the teardown runs");
let feed = app.world().resource::<DeathFeedState>();
assert!(
feed.mirror.is_none(),
"a relog must find no mirror, or the login-while-ghost arm cannot fire"
);
assert!(
feed.died_at.is_none(),
"the release window is the dead session's, and re-arms at the next login"
);
}
}
#[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
);
}
}