the resurrect prompt is a 40-yard latch, and a corpse names itself
- the "Resurrect Now" prompt was never a click: release sends the corpse query, the position is cached, and a per-frame range check (d² ≤ 1600 — 40 yards) raises CORPSE_IN_RANGE, whose Lua raises the prompt, whose button sends CMSG_RECLAIM_CORPSE — a chain we already ran at exactly 40.0; what we were missing was the bones gate: every one of the reference's three latch writers requires the corpse's bones bit clear as well as ownership, and RetrieveCorpse itself carries no guard at all, so our reclaim guid could have named a pile of bones — the gate is in now, at create and on the in-place flip
- a corpse publishes a mouseover: the "Corpse of %s" name plate and the +64 brighten, applied before the publisher's type switch with no type test — any real body qualifies, owner irrelevant, only a bone pile with nothing to take is dropped — and it fires no event, so UnitName("mouseover") over a body stays nil, here as there; sitting refuses the corpse loot click with the client-local red not-standing error and no send; and the insignia doc is corrected — bit 5 unlocks spell 22027 "Remove Insignia", the corpse's actual PvP flag is bit 2, and there is no skinning of players in 1.12.1
- the size tripwire counts code, not lines: 145 flagged files → 15, because in this codebase the comments are the knowledge base and the tests are the gate — one flagged file was 4357 lines of which 5 are code, another 1566 with ZERO — so the budget (unchanged at 1000) now counts non-test, non-comment source, calibrated so the one split on record that actually mattered still fires
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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².
|
||||
|
||||
@@ -34,19 +34,34 @@ pub(super) fn apply_highlight(
|
||||
hovered: Res<Hovered>,
|
||||
hovered_go: Res<HoveredObject>,
|
||||
selection: Res<Selection>,
|
||||
// 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<Vec<Entity>>,
|
||||
) {
|
||||
// [`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<Entity> = 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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <owner>" (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
|
||||
|
||||
@@ -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<u32> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user