Files
benilla-octo/crates/benilla-app/src/target/hover.rs
T
Sam SungandClaude Opus 5.5 1ef11b5bb6 A released player's corpse takes a resurrection: the targeting cursor, the pick and the click bind it as 1.12.1 does (#462)
With Resurrection, Redemption or Rebirth armed and nothing selected, the targeting cursor takes a released party member's corpse: it lights over the corpse in range, and a click casts on it. Before, the cursor stayed grey over a corpse and a click bound nothing.

- **The pick** (`0x480816`, `0x6e6260`). While targeting, a corpse is a candidate only under a word in `0x8600`: under `0x8000` a corpse that is not bones and passes `0x6067d0`, and under `0x200`, tried only without `0x8000`, one that is not bones and fails it. Outside targeting every corpse stays a candidate, as before.
- **The reaction gate `0x6067d0`**: the caster's faction template toward the corpse owner's race template (`ChrRaces`, as `0x5d7120` reads it), friendly at 4 and up. The faction catalog now carries each race's template, and the PvP team loader reads it from there.
- **The cursor**: `0x6e6460`'s corpse leg (`0x6e6719`-`0x6e676d`) with its range tail. `GetMinMaxRange 0x6e3480` pads a corpse like a unit, reading the caster's own reach twice (`0x6e3605`-`0x6e361e`).
- **The click**: `BindTarget`'s corpse arm (`0x6e5f89`-`0x6e6020`), the `0x8000` arm then the `0x200` arm, through the same range merge as a unit (`0x6e602c`). Out of range raises the error and keeps the cursor armed, as the unit arm already does.
- **The wire**: `CMSG_CAST_SPELL` with the corpse bit and the corpse's packed guid, which the client writes for any mask bit in `0x8a02` (`0x7e4e10`) and vmangos reads for either corpse bit (`SpellCastTargetsInfo.cpp:156-157`); `CMSG_USE_ITEM` the same, for Goblin Jumper Cables.

Checked: byte-exact goldens for both packets; unit tests for the pick filter, the cursor verdict (bones, a hostile corpse, out of range, the reach pad), the bind arms from a world click to the sent command, and the reaction gate on the shipped DBCs. Each fails with its piece undone. There is no live run: the probes cannot aim or click in the 3D world. Gates green on macOS; no platform seam touched.

Fixes #450

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 14:01:33 +07:00

1076 lines
44 KiB
Rust

//! The mouseover picks, each frame: the unit under the cursor into [`super::Hovered`] and the
//! GameObject into [`super::HoveredObject`], as the reference's pick (`0x481190` → `0x7089c0`)
//! finds them.
use std::collections::HashSet;
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::camera::primitives::Aabb;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::creature_anim::AnimDriver;
use crate::entities::HeldAttached;
use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::CameraControl;
use crate::ui_script::PointerOverUi;
use benilla_world::billboard::BillboardCard;
use benilla_world::collision::PickOccluder;
use benilla_world::interact::{
ray_mesh_bounds, ray_posed_mesh, CreaturePickPart, GoPickPart, PickParts,
};
use benilla_world::view::WorldCamera;
use super::{Hovered, HoveredObject, PickOcclusion};
/// Trace this frame's world hit, the reference's `CWorld::Intersect` leg of the scene trace
/// (`0x480df0`), through the [`PickOccluder`] set: terrain, the WMO walk-bake faces (about the
/// `0x84` reject mask) and static doodad hulls. Net entities are the object trace, not occluders,
/// so a chest never occludes itself. The picks run unbounded and compare against it after.
pub(super) fn update_pick_occlusion(
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
window: Query<&Window, With<PrimaryWindow>>,
spatial: avian3d::prelude::SpatialQuery,
occluders: Query<(), With<PickOccluder>>,
mut occlusion: ResMut<PickOcclusion>,
) {
occlusion.distance = f32::INFINITY;
occlusion.point = None;
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
if let Some(hit) = spatial.cast_ray_predicate(
ray.origin,
ray.direction,
f32::MAX,
true,
// The mask that reaches every `PickOccluder` collider; the marker picks the set within it.
&benilla_world::collision::WorldCollision::body_filter(),
&|e| occluders.contains(e),
) {
occlusion.distance = hit.distance;
occlusion.point = Some(ray.origin + *ray.direction * hit.distance);
}
}
/// The model instances whose parts are `unit`'s pick geometry: the body, everything it wears, and
/// its mount. The reference's `0x480d90` walks the CM2 attachment tree (`[model+0x1dc]` head,
/// `[+0x1e4]` sibling) and registers each model (`0x713cb0`) under one candidate, so a hit on a
/// held axe is a hit on its wielder. `pub(crate)` for the capture census, which counts the same.
pub(crate) fn pick_model_roots(
unit: Entity,
worn: &[Option<Entity>],
mount: Option<Entity>,
) -> impl Iterator<Item = Entity> + use<'_> {
std::iter::once(unit)
.chain(worn.iter().flatten().copied())
.chain(mount)
}
/// Every model's current skeleton pose, as one [`SystemParam`] (the picker is at Bevy's 16-param
/// limit): the palette rows and the per-part rig link that indexes them.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct PickPose<'w, 's> {
/// The world-space matrices the vertex stage skins with, read from the CPU rows.
palettes: Res<'w, benilla_world::rig_palette::RigPalettes>,
rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>,
}
/// The local identity and what of it and of the corpses the pick takes, one [`SystemParam`] under
/// the picker's param limit.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct TargetHoverState<'w, 's> {
/// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
self_guid: Res<'w, crate::net::SelfGuid>,
/// `0x6e61a0`'s pick flag `0x20`, as the frame began.
picks_self: Res<'w, crate::spell::PicksSelf>,
/// The corpse leg's word, as the frame began.
corpse_pick: Res<'w, crate::spell::CorpsePick>,
/// The corpse reaction gate's inputs (`0x6067d0`).
factions: Option<Res<'w, super::Factions>>,
self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
}
impl TargetHoverState<'_, '_> {
/// Whether an object of this kind is a candidate of this pick, the posed pass and the box
/// fallback alike: every unit and player, and a corpse (`0x480816`) always outside targeting,
/// whose pick flags carry `0x40`, and while targeting only one `0x6e6260` passes. A corpse
/// whose store has not streamed is refused then.
fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool {
match kind {
EntityKind::Unit | EntityKind::Player => true,
EntityKind::Corpse => {
let Some(word) = self.corpse_pick.0 else {
return true;
};
store.is_some_and(|store| {
crate::spell::targeting::corpse_pick_admits(
word,
store,
self.factions.as_deref(),
self.self_store.iter().next(),
)
})
}
_ => false,
}
}
}
/// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while
/// hidden: first person hides them ([`crate::player::apply_self_model_fade`] at alpha 0), where the
/// reference's camera fade clears its player-visible flag `[0xc4d74c]` (`0x482c41`) and
/// `ShouldRender 0x5ec8f0` refuses the active player, so it is never picked.
#[allow(clippy::type_complexity)] // the picker's own part query, borrowed as-is
fn posed_parts<'a>(
kids: &Children,
parts: &'a Query<(
&Mesh3d,
&benilla_world::rig_palette::RigPart,
Option<&InheritedVisibility>,
)>,
is_self: bool,
) -> Vec<(&'a Mesh3d, &'a benilla_world::rig_palette::RigPart)> {
kids.iter()
.filter_map(|c| parts.get(c).ok())
.filter(|(_, _, drawn)| !is_self || drawn.is_none_or(|v| v.get()))
.map(|(mesh, part, _)| (mesh, part))
.collect()
}
/// The unit under the cursor, as the reference's pick (`0x7089c0`) finds it. Broad phase: the ray
/// against the playing sequence's bounds sphere. Pass 1: the ray against the posed render mesh,
/// nearest wins. Pass 2, the mouse pick's retry when pass 1 hit nothing anywhere: every vertex
/// pushed 1 model unit along its skinned normal, a halo of about a yard, won by last frame's pick,
/// else the higher priority (alive 3, dead 2), else the nearer. Every model chained to a unit is
/// its geometry ([`pick_model_roots`]); a unit with no skinned parts takes a box test at pass-1
/// level. Inert while mouse-looking or over the UI.
#[allow(clippy::type_complexity)]
pub(super) fn update_hover(
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
window: Query<&Window, With<PrimaryWindow>>,
rig: Res<CameraControl>,
pointer_over_ui: Res<PointerOverUi>,
occlusion: Res<PickOcclusion>,
// The unit whose plate holds the pointer, as of last frame's plate layout.
plate_hover: Res<crate::vplates::PlateHover>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
pose: PickPose,
// Our own body joins the pick only while a targeting word takes it (`0x6e61a0`).
target_state: TargetHoverState,
// Last frame's pick, which outranks everything in pass 2 (the reference's anti-flicker cache).
mut last_pick: Local<Option<Entity>>,
// Unit roots. An undrawn body is not in the reference's draw list, so it takes no mouseover,
// cursor or click.
roots: Query<
(
Entity,
Has<SelfPlayer>,
&GlobalTransform,
&NetEntity,
Option<&ModelAnimations>,
Option<&AnimDriver>,
Option<&ObjectStore>,
Option<&Children>,
Option<&crate::entities::mount::MountChild>,
Option<&InheritedVisibility>,
Option<&HeldAttached>,
),
With<Guid>,
>,
// The part children of every pick model root: body, worn items and mount.
child_sets: Query<&Children>,
parts: Query<(
&Mesh3d,
&benilla_world::rig_palette::RigPart,
Option<&InheritedVisibility>,
)>,
// The box fallback: every `CreaturePickPart`, the fallback cube included.
meshes: Query<
(
&ChildOf,
&Aabb,
&GlobalTransform,
Option<&InheritedVisibility>,
),
With<CreaturePickPart>,
>,
// The pick's guid and kind; the kind decides which slot of `Hovered` a hit lands in.
units: Query<(&Guid, &NetEntity)>,
) {
hovered.target = None;
hovered.guid = None;
hovered.corpse = None;
hovered.corpse_guid = None;
hovered.distance = f32::MAX;
hovered.refused = false;
// A plate under the pointer is the mouseover, read before the UI gate below, which the plate
// itself trips: the reference's plate OnEnter (`0x7cb850`) publishes `[0xb4e2c8]` ungraded,
// with no world pick behind it. Freelook wins, as the plates hand the mouse back (`0x60f830`).
if !rig.is_looking() {
if let Some(entity) = plate_hover.0 {
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0);
hovered.distance = 0.0; // topmost UI: it beats any world GameObject at a tie
*last_pick = Some(entity);
return;
}
}
if rig.is_looking() || pointer_over_ui.0 {
*last_pick = None;
return;
}
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
let (origin, dir) = (ray.origin, *ray.direction);
let limit = occlusion.distance;
// ── Broad phase, then pass 1 and pass 2 ──
// A candidate: the unit, its pass-2 priority, its parts' mesh ids and its posed joint palette.
let mut candidates: Vec<(Entity, u8, Vec<AssetId<Mesh>>, Vec<Mat4>)> = Vec::new();
// Units with skinned parts: out of the box fallback even when the broad phase rejects them.
let mut faithful: HashSet<Entity> = HashSet::new();
let picks_self = target_state.picks_self.0;
for (entity, is_self, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots
{
if is_self && !picks_self {
continue;
}
// Units, players and corpses: the reference picks every CGObject in one trace and
// switches on type at the end, below.
if !target_state.is_candidate(net.kind, store) {
continue;
}
// Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by
// this rule, not by its exclusion from the box fallback.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let Some(children) = children else { continue };
let worn = held.map_or(&[][..], |h| h.spawned_slots().as_slice());
// Membership in `faithful` is decided before the broad phase rejects; the parts are
// collected below, for the survivors only.
if !children.iter().any(|c| parts.contains(c)) && worn.iter().all(Option::is_none) {
continue; // static/cube unit → the AABB fallback below
}
faithful.insert(entity);
// Broad phase: the playing sequence's bounds sphere, scaled by `OBJECT_FIELD_SCALE_X`. An
// unauthored radius passes (the reference uses the header sphere), as does a mounted unit
// (the reference sizes it from the mount's box).
let clip = match (anims, drv) {
(Some(a), Some(d)) => d.active_anim().and_then(|id| a.find(id)),
_ => None,
};
if let Some(c) = clip.filter(|c| c.bounds_radius > 0.0 && mount_child.is_none()) {
let centre = gt.transform_point(c.bounds_center);
let radius = c.bounds_radius * gt.scale().max_element();
// Reject when the ray's closest approach exceeds the radius; a centre behind the
// origin clamps to t = 0, so a unit we stand inside stays clickable.
let along = (centre - origin).dot(dir).max(0.0);
if (centre - origin - dir * along).length_squared() > radius * radius {
continue;
}
}
// The skinning matrices of one skeleton's parts, as of last frame's propagated pose.
let palette_of =
|sk: &[(&Mesh3d, &benilla_world::rig_palette::RigPart)]| -> Option<Vec<Mat4>> {
sk.first().and_then(|(_, part)| {
let rig = pose.rigs.get(part.0).ok()?;
pose.palettes.world_palette(rig.slot, rig.bones() as usize)
})
};
// The pass-2 ladder (`0x480c90`): alive 3, dead 2, a corpse 2 when
// `CORPSE_DYNFLAG_LOOTABLE`, else 0 (`0x480cec`). A corpse has no UNIT block, so
// `unit_is_dead()` would answer alive for it.
let priority = if net.kind == EntityKind::Corpse {
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 {
3
};
// One candidate per model instance, all resolving to this unit. A partless body is no
// reason to stop: a trigger creature's whole visible self can be what it wears.
for root in pick_model_roots(entity, worn, mount_child.map(|mc| mc.0)) {
let Ok(kids) = child_sets.get(root) else {
continue;
};
let sk = posed_parts(kids, &parts, is_self);
let Some(palette) = palette_of(&sk) else {
continue;
};
candidates.push((
entity,
priority,
sk.iter().map(|(m, _)| m.id()).collect(),
palette,
));
}
}
// Pass 1: the exact posed mesh, nearest wins, unbounded as in the reference; occlusion is one
// compare on the result, below.
let mut best: Option<(f32, Entity)> = None;
for (entity, _, mesh_ids, palette) in &candidates {
for id in mesh_ids {
let Some(t) = ray_posed_mesh(&mesh_assets, *id, palette, origin, dir, false) else {
continue;
};
if best.is_none_or(|(bt, _)| t < bt) {
best = Some((t, *entity));
}
}
}
// ── The box fallback, for skinless units only ──
// The nearest part per parent is the union's hit; `dir` is normalized, so `t` is a world
// distance like the hits above.
for (child_of, aabb, gt, drawn) in &meshes {
// Not drawn, not clickable: a part inherits its root's visibility.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let parent = child_of.parent();
if faithful.contains(&parent) {
continue; // posed-mesh-tested above
}
if !picks_self && roots.get(parent).is_ok_and(|root| root.1) {
continue;
}
// The same candidates as the posed pick. A bone pile lands here: its corpse model has no
// skeleton, so only this box test can pick it.
let Ok((_, parent_net)) = units.get(parent) else {
continue;
};
let store = roots.get(parent).ok().and_then(|root| root.6);
if !target_state.is_candidate(parent_net.kind, store) {
continue;
}
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
if best.is_none_or(|(bt, _)| t < bt) {
best = Some((t, parent));
}
}
}
// Pass 2, when nothing hit exactly: the halo, won by the priority ladder, not distance.
if best.is_none() {
let mut best2: Option<(f32, u32, Entity)> = None;
for (entity, priority, mesh_ids, palette) in &candidates {
let hit = mesh_ids
.iter()
.filter_map(|id| ray_posed_mesh(&mesh_assets, *id, palette, origin, dir, true))
.min_by(f32::total_cmp);
let Some(t) = hit else { continue };
let prio = if *last_pick == Some(*entity) {
u32::MAX // the sticky-hover cache outranks everything
} else {
*priority as u32
};
let wins = best2.is_none_or(|(bt, bp, _)| prio > bp || (prio == bp && t < bt));
if wins {
best2 = Some((t, prio, *entity));
}
}
best = best2.map(|(t, _, e)| (t, e));
}
// Occlusion (`0x480eb4`): a strictly nearer world hit drops the pick, a tie keeps it.
if best.is_some_and(|(t, _)| limit < t) {
best = None;
}
// Switch on type at the end, as the reference does: the one pick lands in the unit slot or the
// corpse slot, and a unit the `IsSelectable` grader refuses in neither.
if let Some((t, entity)) = best {
if let Ok((guid, net)) = units.get(entity) {
if net.kind == EntityKind::Corpse {
hovered.corpse = Some(entity);
hovered.corpse_guid = Some(guid.0);
} else if selectable_pick(entity, &roots, target_state.self_guid.0) {
hovered.target = Some(entity);
hovered.guid = Some(guid.0);
} else {
hovered.refused = true;
}
} else if selectable_pick(entity, &roots, target_state.self_guid.0) {
hovered.target = Some(entity);
} else {
hovered.refused = true;
}
// Set on every arm, the refusal included (`Hovered::refused`).
hovered.distance = t;
}
*last_pick = best.map(|(_, e)| e);
}
/// The hover grader's `IsSelectable` test: `0x4828d0` calls vtable slot 21 at `0x482982`
/// (`0x60be60`, the predicate `SetSelection` runs), and a refusal jumps to `0x482090(0,0)` and
/// `ResetCursor 0x523d30`, past the publisher `0x492890`: no tooltip, cursor, brighten or click.
///
/// It grades the winner and filters no candidate: the pick (`0x480a50`, `0x480d90`, `0x713cb0`,
/// `0x481190`, `0x480df0`) reads no unit field, so a refused unit in front of a chest still takes
/// the pick ([`Hovered::refused`]). A corpse's slot 21 is `CGCorpse_C`'s, so only the unit arms
/// run this. A plate hover skips the grader (`0x7cb869`), but the plate gate `0x60f600` already
/// refuses the same units (`0x60f622`/`0x60f628`). Graded at the publish, so [`Hovered`] is never
/// readable ungraded.
#[allow(clippy::type_complexity)] // the picker's own root query, borrowed as-is
fn selectable_pick(
entity: Entity,
roots: &Query<
(
Entity,
Has<SelfPlayer>,
&GlobalTransform,
&NetEntity,
Option<&ModelAnimations>,
Option<&AnimDriver>,
Option<&ObjectStore>,
Option<&Children>,
Option<&crate::entities::mount::MountChild>,
Option<&InheritedVisibility>,
Option<&HeldAttached>,
),
With<Guid>,
>,
self_guid: Option<u64>,
) -> bool {
let store = roots.get(entity).ok().and_then(|r| r.6);
super::relations::is_selectable(store, self_guid)
}
/// The GameObject picker's state as one [`SystemParam`] (the picker is at Bevy's 16-param limit):
/// the pick-set cache and the stream edges that rebuild it, the sticky pick and the probe's locals.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct GoPickSet<'w, 's> {
added: Query<'w, 's, (), Added<GoPickPart>>,
removed: RemovedComponents<'w, 's, GoPickPart>,
cache: Local<'s, bevy::platform::collections::HashSet<Entity>>,
/// Last frame's pick by net entity, which outranks everything in pass 2.
last_pick: Local<'s, Option<Entity>>,
/// Every billboard card, for [`net_entity_of`]'s first hop.
cards: Query<'w, 's, &'static BillboardCard>,
/// The headless probe's frame counter and say-once report ([`super::hover_probe`]), idle in
/// a player run.
frame: Local<'s, u64>,
report: Local<'s, super::hover_probe::ProbeReport>,
/// `WOW_HOVER_PROBE=lock`'s held target, where the probe rests its aim for the rest of the run.
lock: Local<'s, super::hover_probe::LockedAim>,
}
/// The most `ChildOf` hops [`net_entity_of`] climbs: a malformed-data guard, as real joint chains
/// are single digits deep.
const NET_WALK_HOPS: usize = 64;
/// A picked GameObject part's net entity, the one carrying its `Guid`. A mesh part is its child; a
/// billboard card is a world root linked by [`BillboardCard::follows`] (a joint on a rigged host,
/// else the mirror anchor), and the climb goes on from there. The reference's narrow phase walks
/// the one model's batches, billboards included (`0x7089c0`), so a card hit is a GameObject hit.
fn net_entity_of(
e: Entity,
cards: &Query<&BillboardCard>,
guids: &Query<&Guid>,
child_of: &Query<&ChildOf>,
) -> Entity {
let mut cur = cards
.get(e)
.ok()
.and_then(BillboardCard::follows)
.unwrap_or(e);
for _ in 0..NET_WALK_HOPS {
if guids.contains(cur) {
return cur;
}
let Ok(parent) = child_of.get(cur) else { break };
cur = parent.parent();
}
cur
}
/// What the GameObject gates read beside the object's store: the template cache (GENERIC's
/// eligibility is its `data[1]`, MEETINGSTONE's its `data[2]`), the faction catalog, and the
/// meeting-stone queue, the reference's `[0xb72038]`.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct GoGateInputs<'w> {
pub(crate) templates: Res<'w, crate::go_templates::GameObjectTemplates>,
pub(crate) factions: Option<Res<'w, super::ring::Factions>>,
/// Absent in a net-less build, read as the reference's zeroed `[0xb72038]`: not queued.
pub(crate) stone: Option<Res<'w, crate::ui_dialog_verbs::MeetingStone>>,
}
impl GoGateInputs<'_> {
/// `[0xb72038]`, the area we are queued at, or 0.
pub(crate) fn queued_area(&self) -> u32 {
self.stone.as_deref().map_or(0, |s| s.area)
}
}
pub(super) fn update_hovered_object(
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
window: Query<&Window, With<PrimaryWindow>>,
rig: Res<CameraControl>,
pointer_over_ui: Res<PointerOverUi>,
occlusion: Res<PickOcclusion>,
// The unit pick, earlier in the chain: a unit hover keeps the GameObject halo shut.
unit_hovered: Res<Hovered>,
mut hovered: ResMut<HoveredObject>,
go_parts: Query<Entity, With<GoPickPart>>,
child_of: Query<&ChildOf>,
guids: Query<&Guid>,
stores: Query<&ObjectStore>,
go_gate: GoGateInputs,
self_q: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
parts: PickParts,
mut cache: GoPickSet,
) {
*cache.frame = cache.frame.wrapping_add(1);
let added_parts = &cache.added;
let removed_parts = &mut cache.removed;
let pickable = &mut *cache.cache;
let last_pick = &mut *cache.last_pick;
let cards = &cache.cards;
let resolve_net = |e: Entity| net_entity_of(e, cards, &guids, &child_of);
// The pick set, rebuilt only on GameObject-part stream edges (a type id never changes after
// create). Maintained before any early return: an `Added` edge shows only on its own frame, so
// a rebuild behind the cursor gates would drop parts streamed in while the pointer sat on UI.
// A part despawned between edges misses at the caster.
//
// Transports (TRANSPORT 11, MAP_OBJECT 14, MO_TRANSPORT 15) never join: the reference's model
// resolver `0x5f80e0` gives them no pick geometry and their interaction predicates are
// constant false, so a ship's hull never swallows the pick of an NPC on deck.
if removed_parts.read().next().is_some() || !added_parts.is_empty() {
pickable.clear();
pickable.extend(go_parts.iter().filter(|&e| {
// Through the hit's own resolver, so a card and its mesh siblings agree.
!stores
.get(resolve_net(e))
.is_ok_and(|s| matches!(s.0.gameobject_type_id(), 11 | 14 | 15))
}));
}
hovered.target = None;
hovered.guid = None;
hovered.distance = f32::MAX;
// The sticky pick drops the moment the pointer is not ours.
let yielded = rig.is_looking() || pointer_over_ui.0;
if yielded {
*last_pick = None;
}
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
// The aim: the OS pointer, else the probe's for a cursorless window. Published before the
// yield, as the UI mouse feed reads it as its pointer: skipping it would drop `PointerOverUi`
// and flicker the UI the probe rests on.
let probing = super::hover_probe::armed();
let probe_aim = cache
.lock
.point(camera, cam_tf, |e| parts.get(e).ok().map(|(_, gt, ..)| *gt))
.or_else(|| super::hover_probe::point(window, *cache.frame));
super::hover_probe::publish(probe_aim);
if yielded {
return;
}
let Some(cursor) = window.cursor_position().or(probe_aim) else {
return;
};
if pickable.is_empty() {
*last_pick = None;
return;
}
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
let self_store = self_q.single().ok();
// The probe's census, once a second: where the pickable GameObjects are on screen, so a
// headless miss tells a pick fault from a camera looking elsewhere.
if probing && (*cache.frame).is_multiple_of(60) {
let rows: Vec<String> = pickable
.iter()
.filter_map(|&e| {
let (_, gt, vis, ..) = parts.get(e).ok()?;
let world = gt.translation();
let screen = camera.world_to_viewport(cam_tf, world).ok();
Some(format!(
"{:?}{} → {}",
resolve_net(e),
if vis.get() { "" } else { " (unseen)" },
screen.map_or_else(
|| "off-camera".to_string(),
|p| format!("{:.0},{:.0}", p.x, p.y)
),
))
})
.take(8)
.collect();
info!(
"hover probe census: window {:.0}x{:.0}, {} pickable — {}",
window.width(),
window.height(),
pickable.len(),
rows.join(" · ")
);
}
// Pass 1: the exact resident geometry, nearest wins.
let mut best: Option<(f32, Entity)> =
benilla_world::interact::cast_pick_ray(ray, pickable, &parts, false)
.into_iter()
.next()
.map(|(e, h)| (h.distance, e));
// Pass 2, only when nothing hit anywhere, units included (one resolve in the reference): the
// geometry pushed 1 model unit along its normals, a halo of about a yard that makes a wispy
// herb clickable around its leaves. Every unit outranks every GameObject on that ladder, so a
// unit hover keeps it shut. The reference's sticky pick is cross-type and can hold a
// GameObject over a unit's halo; here the unit wins that frame.
if best.is_none() {
if unit_hovered.target.is_some() {
*last_pick = None;
return;
}
let mut best2: Option<(f32, u32, Entity)> = None;
for (part, hit) in benilla_world::interact::cast_pick_ray_inflated(ray, pickable, &parts) {
let net = resolve_net(part);
let prio = if *last_pick == Some(net) {
u32::MAX // the sticky-hover cache outranks everything
} else {
// `0x480c90`: highlightable 1, else 0. A store not yet streamed reads
// highlightable, the eligibility gate's own default.
stores.get(net).map_or(1, |s| {
let reaction = crate::target::cursor_mode::go_reaction(
go_gate.factions.as_deref(),
s.0.gameobject_faction(),
self_store,
);
let go_guid = guids.get(net).ok().map(|g| g.0);
let overrides = crate::target::cursor_mode::GoOverrides {
channel_owned: crate::target::cursor_mode::fishing_channel_owned(
self_store, go_guid,
),
meeting_stone_queued: crate::target::cursor_mode::meeting_stone_queued(
go_guid
.and_then(|g| go_gate.templates.get(g)?.meeting_stone)
.map(|m| m.area),
go_gate.queued_area(),
),
};
u32::from(crate::target::cursor_mode::go_highlightable(
s, reaction, overrides,
))
})
};
let wins =
best2.is_none_or(|(bt, bp, _)| prio > bp || (prio == bp && hit.distance < bt));
if wins {
best2 = Some((hit.distance, prio, part));
}
}
best = best2.map(|(t, _, e)| (t, e));
}
// Occlusion (`0x480eb4`): a strictly nearer world hit drops the pick, a tie keeps it.
if best.is_some_and(|(t, _)| occlusion.distance < t) {
best = None;
}
let picked = best.map(|(t, e)| (t, resolve_net(e)));
*last_pick = picked.map(|(_, net)| net);
let Some((distance, net_entity)) = picked else {
if probing {
cache.report.say(format!(
"aim {cursor:?} — no GameObject hit (pickable {})",
pickable.len()
));
}
return;
};
let Ok(guid) = guids.get(net_entity) else {
return;
};
// The mouseover-eligibility gate (vtable `+0x54` at `0x482982`): an ineligible object
// publishes the null mouseover, so no tooltip, brighten or cursor; the reference reaches
// `0x492890`, `0x52aa20` and `0x4945e0` only past it. Its null mouseover also hides a unit
// behind the object, which our split picks leave hoverable.
if let Ok(store) = stores.get(net_entity) {
let tmpl = go_gate.templates.get(guid.0);
let reaction = crate::target::cursor_mode::go_reaction(
go_gate.factions.as_deref(),
store.0.gameobject_faction(),
self_store,
);
if !crate::target::cursor_mode::mouseover_eligible(
store.0.gameobject_type_id(),
store.0.gameobject_flags(),
store.0.gameobject_dynamic_flags(),
tmpl.map(|t| t.highlight_column),
reaction,
crate::target::cursor_mode::GoOverrides {
channel_owned: crate::target::cursor_mode::fishing_channel_owned(
self_store,
Some(guid.0),
),
meeting_stone_queued: crate::target::cursor_mode::meeting_stone_queued(
tmpl.and_then(|t| t.meeting_stone).map(|m| m.area),
go_gate.queued_area(),
),
},
) {
if probing {
cache.report.say(format!(
"guid {:#x} type {} at {distance:.1} yd — hover ✗ (tmpl {:?}, highlight {:?}): \
the eligibility gate refused it, so there is no tooltip BY DESIGN",
guid.0,
store.0.gameobject_type_id(),
tmpl.map(|t| t.name.as_str()),
tmpl.map(|t| t.highlight_column),
));
}
return;
}
if probing {
cache.report.say(format!(
"guid {:#x} type {} at {distance:.1} yd — hover ✓, published (tmpl {:?}, highlight \
{:?}, occlusion {:.1})",
guid.0,
store.0.gameobject_type_id(),
tmpl.map(|t| t.name.as_str()),
tmpl.map(|t| t.highlight_column),
occlusion.distance,
));
// `lock` holds only an object past the eligibility gate, the hit kept in the part's
// frame so the aim survives the camera and the object moving.
if super::hover_probe::locks() {
if let Some((t, part)) = best {
if let Ok((_, gt, ..)) = parts.get(part) {
cache.lock.hold(part, gt, ray.origin + *ray.direction * t);
}
}
}
}
}
hovered.target = Some(net_entity);
hovered.guid = Some(guid.0);
hovered.distance = distance;
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
fn card(bone: u16, owner: Entity) -> BillboardCard {
let info = benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
bone,
kind: benilla_formats::BillboardKind::LockZ,
scale_anim: None,
seq_translations: vec![],
};
BillboardCard::following(&info, owner)
}
/// Both card shapes `spawn_billboard_part` makes: following the mirror anchor (a rigless host)
/// and following a nested joint (a rigged one).
#[test]
fn a_billboard_cards_hit_resolves_to_its_gameobject() {
let mut world = World::new();
let net = world.spawn(Guid(0xdead_beef)).id();
// An ordinary mesh part, a direct child.
let mesh_part = world.spawn(ChildOf(net)).id();
// The rigless shape: the card follows the mirror anchor under the net entity.
let anchor = world.spawn(ChildOf(net)).id();
let anchor_card = world.spawn(card(0, anchor)).id();
// The rigged shape: net → model root → root-bone joint → child joint, card on the deepest.
let root = world.spawn(ChildOf(net)).id();
let joint0 = world.spawn(ChildOf(root)).id();
let joint1 = world.spawn(ChildOf(joint0)).id();
let joint_card = world.spawn(card(1, joint1)).id();
// A card whose owner is gone (despawned mid-frame) must not climb into someone else's tree.
let orphan_card = world
.spawn(card(2, Entity::from_raw_u32(9999).unwrap()))
.id();
let resolved = world
.run_system_once(
move |cards: Query<&BillboardCard>,
guids: Query<&Guid>,
child_of: Query<&ChildOf>|
-> Vec<Entity> {
[mesh_part, anchor_card, joint_card, orphan_card, net]
.into_iter()
.map(|e| net_entity_of(e, &cards, &guids, &child_of))
.collect()
},
)
.unwrap();
assert_eq!(
resolved[0], net,
"mesh part: the one-hop case still resolves"
);
assert_eq!(
resolved[1], net,
"anchor-following card: the world root hops through `follows()`, not `ChildOf`"
);
assert_eq!(
resolved[2], net,
"joint-following card: the climb continues up the joint hierarchy, not one hop"
);
assert_ne!(
resolved[3], net,
"a card whose owner is gone resolves to nothing that carries a guid"
);
assert_eq!(resolved[4], net, "the net entity resolves to itself");
}
/// First person hides our own parts (`Visibility::Hidden`), and the posed pick drops them, as
/// `ShouldRender 0x5ec8f0` refuses the active player then; anyone else's stay the root's call.
#[test]
fn our_hidden_parts_leave_the_posed_pick() {
let mut world = World::new();
let root = world.spawn_empty().id();
let rig = world.spawn_empty().id();
let part = |world: &mut World, drawn| {
world
.spawn((
ChildOf(root),
Mesh3d(Handle::default()),
benilla_world::rig_palette::RigPart(rig),
drawn,
))
.id()
};
let shown = part(&mut world, InheritedVisibility::VISIBLE);
part(&mut world, InheritedVisibility::HIDDEN);
let count = move |is_self: bool| {
move |kids: Query<&Children>,
parts: Query<(
&Mesh3d,
&benilla_world::rig_palette::RigPart,
Option<&InheritedVisibility>,
)>| {
posed_parts(kids.get(root).expect("the parts"), &parts, is_self).len()
}
};
assert_eq!(world.run_system_once(count(false)).unwrap(), 2);
assert_eq!(
world.run_system_once(count(true)).unwrap(),
1,
"ours: only the drawn part"
);
world.entity_mut(shown).insert(InheritedVisibility::HIDDEN);
assert_eq!(
world.run_system_once(count(true)).unwrap(),
0,
"first person: nothing of ours to pick"
);
}
/// The picker's inputs with a camera ten yards up the Z axis, looking down it through a
/// 100-pixel square with the cursor at its centre, over whatever stands at the origin.
fn pick_world() -> World {
use bevy::camera::RenderTargetInfo;
use bevy::window::WindowResolution;
let mut world = World::new();
world.init_resource::<CameraControl>();
world.init_resource::<PointerOverUi>();
world.insert_resource(PickOcclusion {
distance: f32::INFINITY,
point: None,
});
world.init_resource::<crate::vplates::PlateHover>();
world.init_resource::<Hovered>();
world.init_resource::<Assets<Mesh>>();
world.init_resource::<benilla_world::rig_palette::RigPalettes>();
world.insert_resource(crate::net::SelfGuid(Some(1)));
world.init_resource::<crate::spell::PicksSelf>();
world.init_resource::<crate::spell::CorpsePick>();
let mut camera = Camera::default();
camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1);
camera.computed.target_info = Some(RenderTargetInfo {
physical_size: UVec2::splat(100),
scale_factor: 1.0,
});
world.spawn((
camera,
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)),
WorldCamera,
));
let mut window = Window {
resolution: WindowResolution::new(100, 100),
..Default::default()
};
window.set_cursor_position(Some(Vec2::splat(50.0)));
world.spawn((window, PrimaryWindow));
world
}
/// One drawn pick box around the origin, the box fallback's geometry for a partless body.
fn pick_box(world: &mut World, body: Entity) {
world.spawn((
ChildOf(body),
CreaturePickPart,
Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)),
GlobalTransform::default(),
InheritedVisibility::VISIBLE,
));
}
/// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes
/// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts.
#[test]
fn our_body_joins_the_pick_only_while_the_word_takes_it() {
let mut world = pick_world();
// Us, at the origin under the cursor, with one drawn pick box.
let me = world
.spawn((
SelfPlayer,
Guid(1),
NetEntity {
kind: EntityKind::Player,
display_id: None,
scale: 1.0,
},
GlobalTransform::default(),
InheritedVisibility::VISIBLE,
))
.id();
pick_box(&mut world, me);
let hover = |world: &mut World, picks: bool| {
world.resource_mut::<crate::spell::PicksSelf>().0 = picks;
world
.run_system_cached(update_hover)
.expect("the pick runs");
world.resource::<Hovered>().target
};
assert_eq!(hover(&mut world, false), None, "not ours to pick");
assert_eq!(hover(&mut world, true), Some(me), "a word that takes us");
assert_eq!(hover(&mut world, false), None, "and not once it ends");
}
/// A corpse is a candidate outside targeting (`0x5c` carries flag `0x40`); while targeting,
/// only under a word in `0x8600` and only one `0x6e6260` passes, as
/// [`crate::spell::CorpsePick`] carries the standing word to the picker.
#[test]
fn a_corpse_joins_the_pick_as_the_word_admits_it() {
use crate::spell::targeting::corpse_fixture as fx;
let hover = |corpse: ObjectStore, word: Option<u16>| {
let mut world = pick_world();
world.insert_resource(fx::factions());
world.init_resource::<crate::spell::SpellTargeting>();
// Us, a human, away from the ray: the reaction gate's other side.
world.spawn((
SelfPlayer,
Guid(1),
GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)),
fx::caster(1.5),
));
let body = world
.spawn((
Guid(fx::CORPSE),
NetEntity {
kind: EntityKind::Corpse,
display_id: None,
scale: 1.0,
},
GlobalTransform::default(),
InheritedVisibility::VISIBLE,
corpse,
))
.id();
pick_box(&mut world, body);
if let Some(word) = word {
world.resource_mut::<crate::spell::SpellTargeting>().enter(
fx::RESURRECTION,
crate::spell::CastCommit::Spell,
word,
);
}
world
.run_system_cached(crate::spell::targeting::publish_corpse_pick)
.expect("the word publishes");
world
.run_system_cached(update_hover)
.expect("the pick runs");
let hovered = *world.resource::<Hovered>();
assert_eq!(hovered.target, None, "a corpse is never the unit slot");
hovered.corpse.inspect(|&e| assert_eq!(e, body)).is_some()
};
let friend = || fx::corpse(fx::HUMAN, false);
let foe = || fx::corpse(fx::ORC, false);
let bones = || fx::corpse(fx::HUMAN, true);
// Not targeting: every corpse, bones and foes included.
for (corpse, what) in [(friend(), "friend"), (foe(), "foe"), (bones(), "bones")] {
assert!(hover(corpse, None), "{what} outside targeting");
}
// Resurrection's word: a friend's corpse, never a foe's or bones.
assert!(hover(friend(), Some(0x8000)));
assert!(!hover(foe(), Some(0x8000)), "a hostile corpse");
assert!(!hover(bones(), Some(0x8000)), "bones");
// The enemy bit takes the other side.
assert!(hover(foe(), Some(0x0200)));
assert!(!hover(friend(), Some(0x0200)));
// A heal word sets no flag `0x40`: the corpse is out of the pick, so a unit behind it
// is reachable.
assert!(!hover(friend(), Some(0x0002)));
assert!(!hover(friend(), Some(0x0040)), "a ground word");
}
/// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe,
/// which is its whole pick surface.
#[test]
fn a_units_pick_geometry_is_every_model_chained_to_it() {
let unit = Entity::from_raw_u32(1).unwrap();
let main_hand = Entity::from_raw_u32(2).unwrap();
let helm = Entity::from_raw_u32(3).unwrap();
let mount = Entity::from_raw_u32(4).unwrap();
let bare: Vec<_> = pick_model_roots(unit, &[], None).collect();
assert_eq!(bare, vec![unit], "a unit wearing nothing is just its body");
// Empty slots, as in the real `HeldAttached` (main, off, ranged, helm, both shoulders,
// ammo, quiver), are not candidates.
let worn = [
Some(main_hand),
None,
None,
Some(helm),
None,
None,
None,
None,
];
let dressed: Vec<_> = pick_model_roots(unit, &worn, Some(mount)).collect();
assert_eq!(
dressed,
vec![unit, main_hand, helm, mount],
"body, then every worn model, then the mount — all one unit's geometry"
);
// A body that draws nothing: the axe is the whole hit surface.
let trigger: Vec<_> = pick_model_roots(unit, &worn[..1], None).collect();
assert!(
trigger.contains(&main_hand),
"a body with no geometry of its own still offers what it holds"
);
}
}