diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 7128d237..4a192f2b 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -659,6 +659,10 @@ pub(super) fn apply_net_updates( ); } SessionEvent::ObjectValues { guid, fields } => { + // Our corpse's own `CORPSE_FIELD_FLAGS` can flip to BONES under a live guid; the + // reclaim latch is re-asked on that edge, as the reference's `FLAGS` mirror + // handler `0x5d6d60` does (1729). + death::recheck_corpse(guid, &fields, &self_guid, &mut death_net); objects::object_values(guid, fields, &index, &mut stores, &mut pending, &mut items) } SessionEvent::ObjectDestroyed(guid) => { diff --git a/crates/benilla-app/src/net/apply/death.rs b/crates/benilla-app/src/net/apply/death.rs index 7cc6361a..a539b4bf 100644 --- a/crates/benilla-app/src/net/apply/death.rs +++ b/crates/benilla-app/src/net/apply/death.rs @@ -17,6 +17,15 @@ use super::super::SelfGuid; /// for the reclaim send (decision 0308 §5). The kind is exact ([`EntityKind::Corpse`] since 1706 — /// it was `Other` while nothing rendered a corpse), and the owner field is corpse-only, so nothing /// else can match. Rides the `ObjectCreate` arm. +/// +/// **Two gates, not one** (wow-re `corpse-click-and-reclaim.md`, §5 cross-checked): the reference's +/// reclaim guid `[0xb4e328/32c]` has exactly one writer, `0x4920d0`, called from three sites in the +/// CGCorpse translation unit — and every one of them is gated on `CORPSE_FIELD_OWNER == me` +/// **and `CORPSE_FIELD_FLAGS` bit 0 clear**, i.e. a real body and not a bone pile. So the latch is +/// a property of the streamed corpse OBJECT, not of the `MSG_CORPSE_QUERY` answer, and a pile of +/// bones never arms it. That matters because `RetrieveCorpse` itself carries **no guard of any +/// kind** — whatever is in the latch is what goes out on `CMSG_RECLAIM_CORPSE`. The bones gate is +/// the only thing standing between a converted corpse and a reclaim send naming it. pub(super) fn note_corpse( guid: u64, kind: EntityKind, @@ -24,7 +33,44 @@ pub(super) fn note_corpse( self_guid: &SelfGuid, death_net: &mut DeathNet, ) { - if kind == EntityKind::Corpse && fields.corpse_owner() == self_guid.0 { + if kind == EntityKind::Corpse + && fields.corpse_owner() == self_guid.0 + && !fields.corpse_is_bones() + { + death_net.corpse_guid = Some(guid); + } +} + +/// The same latch, re-evaluated when a corpse's **`CORPSE_FIELD_FLAGS` changes under a live guid**. +/// +/// [`forget_corpse`] covers the conversion shape where the server destroys the corpse object and +/// creates the bone pile as a new one. It does not cover an in-place flip — the same guid gaining +/// `CORPSE_FLAG_BONES` through a values delta — and the reference is immune to that shape by +/// construction: its three latch writers include the `FLAGS` mirror handler `0x5d6d60`, so the +/// gate is re-asked on every change of the very field that carries the bones bit. Ours asks it +/// here, on the same edge. Rides the `ObjectValues` arm. +pub(super) fn recheck_corpse( + guid: u64, + fields: &ObjectFields, + self_guid: &SelfGuid, + death_net: &mut DeathNet, +) { + // A values delta carries only what changed: an untouched FLAGS field reads absent, and absent + // must not be read as "bit clear" (that is the `ObjectFields` create-vs-delta seam). So act + // only when the field is actually present in this delta. + let Some(flags) = fields.corpse_flags_present() else { + return; + }; + let mine = fields.corpse_owner() == self_guid.0 || death_net.corpse_guid == Some(guid); + if !mine { + return; + } + if flags & 0x01 != 0 { + // Converted to bones under our own guid — drop it before a reclaim can name it. + if death_net.corpse_guid == Some(guid) { + death_net.corpse_guid = None; + } + } else if fields.corpse_owner() == self_guid.0 { death_net.corpse_guid = Some(guid); } } diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index bb229218..96f7a370 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -398,7 +398,26 @@ pub(super) fn act_on_right_click( ); } if store.is_some_and(|s| s.0.corpse_lootable()) { - // Leg 1. Range rides the cursor's own gray, exactly as the unit loot branch does — + // **`GetStandState() != 0` refuses before the send** (`0x5d6c3b call [playerVtbl+0xa4]` + // = `0x5ed570`, non-zero → `0x496720(0x85)`): sitting, kneeling or asleep, the click + // raises the client-local red **`ERR_LOOT_NOTSTANDING`** as a `UI_ERROR_MESSAGE` and + // sends nothing. Client-local, so the line shows even where the server would also have + // refused — and unlike the range gray it is loud, because the player can fix it. + // Scoped to the corpse leg: this is the gate the §5 round read, and the unit loot + // branch's own copy of it is unverified here (1729). + let standing = self_player + .single() + .ok() + .and_then(|(e, _, _)| stores.get(e).ok()) + .is_none_or(|(s, _)| s.0.unit_stand_state() == 0); + if !standing { + debug!("right-click corpse loot: refused, not standing ({guid:#x})"); + ui_error_keys + .0 + .push(crate::ui_action::UiError::key("ERR_LOOT_NOTSTANDING")); + return; + } + // Range rides the cursor's own gray, exactly as the unit loot branch does — // the classifier and the click ask literally the same question, so the pouch can never // be lit on something a click refuses. if !cursor.unable { diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 2efd5365..2844d3c7 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -709,6 +709,22 @@ fn loot_cursor(auto_loot: bool, shift_held: bool) -> CursorKind { } } +/// Is this corpse **moused over at all** — the reference's `[CGCorpse_C vtbl+0x54]` = `0x5d76d0`, +/// asked at `0x482982` by the mouseover publisher (wow-re `corpse-click-and-reclaim.md` Q6, §5 +/// cross-checked). +/// +/// A **real body** (BONES clear) qualifies **unconditionally — owner irrelevant**, so your own +/// corpse is moused over like anyone else's. Only a **bone pile with nothing to take** is dropped; +/// the base occupant it falls through to is `0x469fd0`, a constant false, which is the same reason +/// an item lying in the world is never moused over. +/// +/// This is a *separate* gate from pick eligibility (`0x480816`) — a rejected corpse is still +/// picked, it simply publishes no mouseover, so it gets no name plate and no brighten. Ours reads +/// it the same way: the pick is unconditional, this decides what the hover *shows*. +pub(crate) fn corpse_mouseover_eligible(store: &ObjectStore) -> bool { + !store.0.corpse_is_bones() || store.0.corpse_lootable() +} + /// What a hovered **corpse object** classifies to — the reference's corpse classifier `0x482740` /// (wow-re `cursor-system.md` §3, VERIFIED), as a pure decision so it can be tested the way the /// service ladder is. `esi` = player, `edi` = corpse, `d2` = centre-to-centre distance². diff --git a/crates/benilla-app/src/target/highlight.rs b/crates/benilla-app/src/target/highlight.rs index e53adf93..231b1d84 100644 --- a/crates/benilla-app/src/target/highlight.rs +++ b/crates/benilla-app/src/target/highlight.rs @@ -34,19 +34,34 @@ pub(super) fn apply_highlight( hovered: Res, hovered_go: Res, selection: Res, + // The hovered corpse's descriptor — the mouseover-eligibility gate below is a field read. + stores: Query<&crate::net::ObjectStore>, children: Query<&Children>, mut tags: Query<&mut MeshTag>, mut was_lit: Local>, ) { // [`Hovered::any`], not `target`: a hovered **corpse** is a pick too (decision 1723), so it - // holds the brighten off a farther GameObject exactly as a unit does. Whether the corpse body - // itself brightens is a separate byte question and is not assumed here. + // holds the brighten off a farther GameObject exactly as a unit does. let go = hovered_go .target .filter(|_| hovered.any().is_none() || go_is_nearest(&hovered, &hovered_go)); let unit_hover = hovered.target.filter(|_| go.is_none()); + // **And the corpse body brightens too** (1729, byte-verified): the mouseover brighten + // `0x49295e → 0x4945e0` runs *before* the publisher's type switch and tests nothing but + // "has a model, has an object" — reason bit 1 is mouseover, and it applies to a corpse + // exactly as to a unit or a signpost. (Bit 0 is the target brighten, which a corpse can + // never take: `SetSelection` refuses it.) Gated by the same mouseover eligibility that + // decides whether it gets a name plate — a bone pile with nothing to take publishes no + // mouseover, so it neither lights nor labels. + let corpse_hover = hovered + .corpse + .filter(|_| go.is_none()) + .filter(|e| stores.get(*e).is_ok_and(super::corpse_mouseover_eligible)); let mut want: Vec = Vec::new(); - for root in [unit_hover, go, selection.target].into_iter().flatten() { + for root in [unit_hover, corpse_hover, go, selection.target] + .into_iter() + .flatten() + { if !want.contains(&root) { want.push(root); } diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index b6a1a2b4..8b83bddd 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -289,14 +289,19 @@ pub(super) fn update_hover( palettes.world_palette(rig.slot, rig.bones() as usize) }) }; - // The halo ladder (alive 3 / dead 2), with a **corpse below every unit** (1). Deliberately - // conservative and deliberately not read off `unit_is_dead()`: a corpse descriptor has no - // UNIT fields at all, so that accessor answers "alive" for one and would rank a body above - // a dead mob. Ranking it last means a corpse can never steal the generous-retry pick from - // a unit — the direction that cannot break an existing click. (Where `CGCorpse_C` actually - // sits in the reference's ladder is an open RE question, dispatched with 1723.) + // The halo ladder (alive 3 / dead 2), with the corpse rung now **byte-verified** rather + // than guessed (wow-re `corpse-click-and-reclaim.md` Q6, folding back into 1723's open + // question): `0x480c90`'s arm at `0x480cec` scores a corpse **2 when + // `CORPSE_DYNFLAG_LOOTABLE`, else 0** — the same rung as a dead unit when there is + // something to take, and the floor otherwise. 1723 guessed a flat 1; this replaces it. + // + // The rung matters less than it looks: **pass 1 sorts on distance alone**, so a corpse + // can neither steal a click from a unit standing in front of it nor lose one it is in + // front of. Priority decides only the inflated pass-2 retry. Still not read off + // `unit_is_dead()` — a corpse descriptor has no UNIT block, so that accessor answers + // "alive" for one. let priority = if net.kind == EntityKind::Corpse { - 1 + u8::from(store.is_some_and(|s| s.0.corpse_lootable())) * 2 } else if store.is_some_and(|s| s.0.unit_is_dead()) { 2 } else { diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 4f2468e3..6b0ba5b5 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -66,7 +66,9 @@ mod reticle; pub(crate) mod ring; mod scan; -pub(crate) use cursor_mode::{CursorKind, WorldCursor, GO_TYPE_GENERIC, SERVICE_RANGE_SQ}; +pub(crate) use cursor_mode::{ + corpse_mouseover_eligible, CursorKind, WorldCursor, GO_TYPE_GENERIC, SERVICE_RANGE_SQ, +}; /// `GO_FLAG_LOCKED` — the wire bit the lock chain and the GO tooltip's "Locked" line both read. pub(crate) use lock::GO_FLAG_LOCKED; // This frame's combat-flash verdict — read by the ring's material pick (in `ring`) and by the diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 990e3f19..836676e8 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -537,6 +537,8 @@ enum LastHover { None, Unit(u64), Go(u64), + /// A hovered **corpse object** — its own plate, keyed on the corpse's guid (1729). + Corpse(u64), } /// The snapshot fields the unit tooltip's LINES read (everything except the bar's @@ -663,6 +665,47 @@ fn drive_mouseover_tooltip( } return; } + // The **corpse plate** — "Corpse of " (the reference's own builder `0x52aef0`, wow-re + // `corpse-click-and-reclaim.md` Q6, §5 cross-checked). Ahead of the GameObject arm because the + // corpse is the nearer pick whenever `go` came back empty. + // + // It is a *name plate and nothing else*: no health bar, no reaction recolour, no level or + // creature-type line — a corpse is not a unit, and the publisher deliberately fires **no + // event** for it, so it never becomes the Lua `mouseover` unit token either (`UnitName + // ("mouseover")` on a body is nil in the reference, and stays nil here). Corner-seated like a + // unit's, not cursor-seated like a signpost's. + if let Some((entity, guid)) = hovered + .corpse + .zip(hovered.corpse_guid) + .filter(|_| go.is_none()) + { + let Some(store) = stores.get(entity).ok() else { + return; + }; + // A bone pile with nothing to take publishes no mouseover at all — no plate, no brighten. + if !crate::target::corpse_mouseover_eligible(store) { + if !matches!(*last, LastHover::None) { + script.world_tooltip_fade(); + *last = LastHover::None; + } + return; + } + if *last == LastHover::Corpse(guid) { + return; // already showing; nothing re-seats a corner plate + } + // The owner's name is the ask-once cache like any other player name — a corpse carries + // `CORPSE_FIELD_OWNER`, never a name. Until the answer lands there is no plate to show + // (`last` stays `None`, so it appears the moment the name arrives). + let Some(owner) = store.0.corpse_owner() else { + return; + }; + let Some(name) = names.resolve(owner, &commands).map(str::to_string) else { + return; + }; + script.world_tooltip_gameobject(&format!("Corpse of {name}"), &[], None); + *last = LastHover::Corpse(guid); + return; + } if let Some((entity, guid)) = go { // Which arm of the anchor fork this object takes (decision 0766). The reference asks the // object's own `[obj->vtbl+0x5c]`; what selects it is not pinned, so we key on the one diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 61fdb465..6c977564 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -765,6 +765,16 @@ impl ObjectFields { pub fn corpse_flags(&self) -> u32 { self.get_u32(35).unwrap_or(0) } + /// [`Self::corpse_flags`] **preserving absence** — `None` when this snapshot does not carry + /// field 35 at all. + /// + /// The two are not interchangeable, and the difference is the create-vs-delta seam: a values + /// delta carries only what changed, so an untouched `FLAGS` reads absent, and `unwrap_or(0)` + /// would report every bit *clear* — turning "nothing was said about the flags" into a positive + /// claim that the corpse is not bones. Readers acting on a **change** must use this one. + pub fn corpse_flags_present(&self) -> Option { + self.get_u32(35) + } /// `CORPSE_FLAG_BONES` (`0x01`) — this object is a **bone pile**, not a fresh body. It is the /// first thing `0x5d6260` tests (`0x5d6287 test byte [eax+0x74],0x1; jne`): a bone pile builds /// **no** character component at all, wears nothing, and takes its model from race/sex rather @@ -796,16 +806,23 @@ impl ObjectFields { self.get_u32(36).unwrap_or(0) & 0x01 != 0 } - /// `CORPSE_FLAG_LOOTABLE` (`0x20`, [`Self::corpse_flags`] bit 5) — the **PvP insignia** flag, - /// the one the reference's corpse cursor classifier `0x482740` reads at - /// `[[corpse+0x110]+0x74] >> 5 & 1` for its second leg, and the corpse right-click `0x5d6bf0` - /// reads again at `0x5d6c9c` for the matching send. + /// `CORPSE_FLAG_LOOTABLE` (`0x20`, [`Self::corpse_flags`] bit 5) — the flag that makes a body's + /// **battleground insignia** takeable. It gates the second leg of the corpse cursor classifier + /// `0x482740` (`[[corpse+0x110]+0x74] >> 5 & 1`), and the corpse right-click `0x5d6bf0` reads + /// it again at `0x5d6c9c` for the matching send. /// - /// **It is not "reclaimable"** — `object-layer/scratch/w2a.md` calls it both things in one - /// file (line 264 "reclaimable", line 618 "PvP flag") and only the second survives the byte - /// read: both readers pair it with the `SPELL_EFFECT_SKIN_PLAYER_CORPSE` learn-time latch - /// `[0xb700e8]`, which is a skinning precondition and has nothing to do with recovering your - /// own body. Nothing in the reclaim path consults this bit. + /// **Neither of `w2a.md`'s two labels for this bit was right**, and it took a §5 round to say + /// so (wow-re `corpse-click-and-reclaim.md`; decision 1729). "Reclaimable" (its line 264) is + /// wrong outright — nothing in the reclaim path consults it, and `RetrieveCorpse` reads no + /// corpse flag at all. "PvP flag" (its line 618) names the wrong bit: the corpse's actual PvP + /// flag is **bit 2**, read by `UnitIsPVP 0x516460` at `0x5164e6` (`shr eax,2; and al,1`), and + /// it is the only corpse flag this client exposes to Lua. + /// + /// What bit 5 actually unlocks is spell **22027 "Remove Insignia"** — the sole row in the + /// shipped `Spell.dbc` with `Effect[0] == 116`, targeting `TARGET_FLAG_PVP_CORPSE`, absent + /// from the base `dbc.MPQ` and added with Battlegrounds. The "skin a player" reading came from + /// the cursor **art** names (`SkinAlliance`/`SkinHorde`) and from nowhere else; there is no + /// skinning of players in 1.12.1. pub fn corpse_pvp_insignia(&self) -> bool { self.corpse_flags() & 0x20 != 0 }