Files
benilla-octo/crates/benilla-app/src/target/hover.rs
T
chairmanpow 706cd717a3 merge: absorb samwhosung/main on top of the upstream merge
Same fork policy: upstream structure wins, local-unique re-instated.

Cargo.toml unions both measured dev-profile philosophies (codegen-units=1 +
lto=off from samwhosung, debug=line-tables-only + split-debuginfo from the
fork's disk measurements).

glue_booth.rs takes samwhosung's typed GlueScene (token/of_race/from_model_path)
and re-adds the fork's TurtleWoW races (Goblin=9, BloodElf=10) as variants;
their glue fog stays on Human's row, exactly as the fork's scene_fog fallback did.

ui script takes samwhosung's mouseover/hyperlink rework (world_mouseover,
minimap_blip, hyperlink.rs) over the fork's inline hover_link/span_at/pointer
path — the rework covers the same OnHyperlinkEnter/Leave behaviour.

game_plugins.rs keeps the fork's re-measured ceilings (466 / 5,509); mail.rs
keeps the explicit 13-tuple with a float daysLeft; MAP.md takes the superset.
2026-10-12 17:24:31 +01:00

1484 lines
62 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 = PickOcclusion::default();
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;
};
occlusion.ray = Some(ray.direction);
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 and the pick filter's possession 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>>,
/// The unit and player filter while a word stands (`0x480610`).
units: crate::spell::targeting::PickChecks<'w, 's>,
}
impl TargetHoverState<'_, '_> {
/// `0x480610`'s two exclusions, which come first and which a pick whose flags carry `0x20`
/// skips (`48062c`, [`crate::spell::PicksSelf`]; `0x481050` ORs it in only for a word
/// `0x6e61a0` passes, so outside targeting they always run): the active player (`480638`),
/// and a unit they possess, an Eye of Kilrogg or a Mind Controlled target
/// ([`super::is_possessed_by`], `48064f`-`480674`), so the cursor and the click pass through
/// it to what lies behind.
fn excludes(&self, is_self: bool, store: Option<&ObjectStore>) -> bool {
!self.picks_self.0 && (is_self || super::is_possessed_by(store, self.self_guid.0))
}
/// Whether an object of this kind is a candidate of this pick, the posed pass and the box
/// fallback alike: every unit and player outside targeting but the two [`Self::excludes`]
/// drops, and while targeting the ones `0x480610` passes for the word
/// ([`crate::spell::targeting::PickChecks::admits_unit`]); 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. `is_self` is the active
/// player's own body. The hover and the press, which latches this pick, share it.
fn is_candidate(
&self,
entity: Entity,
kind: EntityKind,
is_self: bool,
store: Option<&ObjectStore>,
) -> bool {
match kind {
EntityKind::Unit | EntityKind::Player => {
!self.excludes(is_self, store) && self.units.admits_unit(entity, store)
}
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 hover after this frame's UI walk.
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;
hovered.plate = 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
hovered.plate = true;
*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();
for (entity, is_self, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots
{
// 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(entity, net.kind, is_self, 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
}
// 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 root = roots.get(parent).ok();
let is_self = root.as_ref().is_some_and(|root| root.1);
let store = root.and_then(|root| root.6);
if !target_state.is_candidate(parent, parent_net.kind, is_self, 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>>,
/// Whether the player is in a battleground, whose objective faction term the gate skips.
pub(crate) battleground: super::cursor_mode::BattlegroundMap<'w>,
}
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.
//
// TRAP (6) never joins either: a trap is proximity-triggered, never clicked, so the reference
// registers no mouse geometry for one. Arathi Basin's visual-only aura GameObjects
// (180100-180102, type 6) otherwise sit at the node banner's centre and win the pick from it —
// their faction is hostile to everyone, so the inverted TRAP term keeps them highlightable and
// the banner is never reached, whatever the battleground gate does.
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(), 6 | 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 mouseover-eligibility gate (`+0x54`), as a predicate over a picked entity: a GameObject
// the cursor will never publish must not win the pick and bury the interactable behind it. A
// battleground's visual-only aura GameObjects (Arathi Basin 180100-180102, type 6 TRAP, never
// highlightable to a friendly or neutral player) sit at the node banner's centre, so without
// this they take every pick and the banner is unreachable. A store not yet streamed reads
// eligible, the gate's own default.
let mouseover_ok = |net: Entity| -> bool {
let Ok(store) = stores.get(net) else {
return true;
};
let go_guid = guids.get(net).ok().map(|g| g.0);
let tmpl = go_guid.and_then(|g| go_gate.templates.get(g));
let reaction = crate::target::cursor_mode::go_reaction(
go_gate.factions.as_deref(),
store.0.gameobject_faction(),
self_store,
);
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, go_guid,
),
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(),
),
battleground: go_gate.battleground.active(),
},
)
};
// 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 nearest resident geometry the eligibility gate will publish, nearest wins. Every
// hit is asked for, nearest-first, so an ineligible front object is skipped rather than allowed
// to bury the eligible one behind it.
let mut best: Option<(f32, Entity)> = None;
for (part, hit) in benilla_world::interact::cast_pick_ray(ray, pickable, &parts, true) {
if mouseover_ok(resolve_net(part)) {
best = Some((hit.distance, part));
break;
}
}
// 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);
if !mouseover_ok(net) {
continue;
}
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(),
),
battleground: go_gate.battleground.active(),
};
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 pick above already skips
// an ineligible object, so this normally passes; it stays as the probe's report.
if let Ok(store) = stores.get(net_entity) {
let tmpl = go_gate.templates.get(guid.0);
if !mouseover_ok(net_entity) {
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 crate::spell::targeting::corpse_fixture as fx;
use bevy::ecs::system::RunSystemOnce;
/// The plate hover the pick reads is this frame's walk's: a plate's leave publishes the loss
/// inside the walk (`0x7cb8b0`), so a pick a frame behind would publish the plate again.
#[test]
fn the_pick_reads_the_plate_hover_of_this_frames_walk() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(crate::test_support::runs_before(
&mut app,
crate::vplates::seed_plate_hover,
update_hover
));
}
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,
ray: 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>();
world.init_resource::<crate::spell::targeting::UnitPick>();
world.insert_resource(crate::net::Reputations(Vec::new()));
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");
}
/// A unit or player at depth `z` on the cursor's ray with one drawn pick box, of this store.
fn unit_at(world: &mut World, guid: u64, z: f32, store: ObjectStore) -> Entity {
let at = GlobalTransform::from_translation(Vec3::new(0.0, 0.0, z));
let body = world
.spawn((
Guid(guid),
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
at,
InheritedVisibility::VISIBLE,
store,
))
.id();
world.spawn((
ChildOf(body),
CreaturePickPart,
Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)),
at,
InheritedVisibility::VISIBLE,
));
body
}
/// A store of `UNIT_FIELD_FACTIONTEMPLATE` (35), `UNIT_FIELD_FLAGS` (46) and
/// `UNIT_FIELD_HEALTH` (22), on [`fx::factions`]' rows: template 1 the caster's Human, 2 an Orc.
fn unit_store(template: u32, flags: u32, health: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(35, template),
(46, flags),
(22, health),
]))
}
/// A living player of the caster's faction: assistable, not attackable.
fn friend() -> ObjectStore {
unit_store(1, 0x8, 100)
}
/// A living Orc creature: attackable, not assistable.
fn foe() -> ObjectStore {
unit_store(2, 0, 100)
}
/// The pick over `word`, the way a frame runs it: the word published, its flags `0x20` and the
/// unit legs both, then the pick.
fn pick_under(world: &mut World, word: Option<u16>) -> Hovered {
let mut targeting = world.resource_mut::<crate::spell::SpellTargeting>();
match word {
Some(word) => targeting.enter(fx::RESURRECTION, crate::spell::CastCommit::Spell, word),
None => targeting.clear(),
}
world
.run_system_cached(crate::spell::targeting::publish_unit_pick)
.expect("the word publishes");
world
.run_system_cached(crate::spell::targeting::publish_picks_self)
.expect("the self bit publishes");
world
.run_system_cached(update_hover)
.expect("the pick runs");
*world.resource::<Hovered>()
}
/// Us, a human away from the ray, the faction tables, and the units a case puts on it.
fn unit_pick_world() -> World {
let mut world = pick_world();
world.insert_resource(fx::factions());
world.init_resource::<crate::spell::SpellTargeting>();
world.spawn((
SelfPlayer,
Guid(1),
GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)),
fx::caster(1.5),
));
world
}
/// `0x480610` while a word stands: a unit the word cannot take is no candidate, so the one
/// behind it takes the pick, where outside targeting the nearer wins whatever it is. The
/// front unit is 5 yards nearer the camera than the rear one.
#[test]
fn a_unit_the_word_cannot_take_lets_the_pick_through_to_the_one_behind() {
const HEAL: u16 = 0x0100;
const ENEMY: u16 = 0x0080;
const RESURRECTION: u16 = 0x8000;
const SKINNING: u16 = 0x0402;
// (word, the front unit it refuses, the rear unit it takes, what stands behind it)
let cases: [(&str, u16, ObjectStore, ObjectStore); 6] = [
("a foe before a friend, under a heal", HEAL, foe(), friend()),
(
"a corpse before a friend, under a heal",
HEAL,
unit_store(1, 0x8, 0),
friend(),
),
(
"a friend before a foe, under an enemy word",
ENEMY,
friend(),
foe(),
),
(
"the living before a corpse, under Resurrection",
RESURRECTION,
friend(),
unit_store(1, 0x8, 0),
),
(
"the living before a corpse, under Skinning",
SKINNING,
foe(),
unit_store(2, 0, 0),
),
(
"a unit immune to players before a friend, under a heal",
HEAL,
unit_store(1, 0x108, 100),
friend(),
),
];
for (what, word, front_store, rear_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
let rear = unit_at(&mut world, 11, 0.0, rear_store);
// Outside targeting the nearer unit wins, whoever it is.
assert_eq!(
pick_under(&mut world, None).target,
Some(front),
"{what}: outside targeting"
);
let hovered = pick_under(&mut world, Some(word));
assert_eq!(
hovered.target,
Some(rear),
"{what}: the word takes the rear"
);
assert_eq!(hovered.guid, Some(11), "{what}");
assert_eq!(
pick_under(&mut world, None).target,
Some(front),
"{what}: and back outside targeting"
);
}
}
/// The control of the table above: the same front unit is the pick when the word takes it.
#[test]
fn a_unit_the_word_takes_keeps_the_pick_in_front_of_the_one_behind() {
let cases: [(&str, u16, ObjectStore); 4] = [
("a friend under a heal", 0x0100, friend()),
("a foe under an enemy word", 0x0080, foe()),
("a corpse under Resurrection", 0x8000, unit_store(1, 0x8, 0)),
("a foe under a plain unit word", 0x0002, foe()),
];
for (what, word, front_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
let rear_store = if word == 0x8000 {
unit_store(1, 0x8, 0)
} else {
friend()
};
unit_at(&mut world, 11, 0.0, rear_store);
assert_eq!(
pick_under(&mut world, Some(word)).target,
Some(front),
"{what}"
);
}
}
/// With nothing behind it, a unit the word cannot take leaves the pick empty, which is no
/// refusal: `IsSelectable` grades the winner only, and there is none.
#[test]
fn a_lone_unit_the_word_cannot_take_is_no_pick_at_all() {
let mut world = unit_pick_world();
unit_at(&mut world, 10, 0.0, foe());
let hovered = pick_under(&mut world, Some(0x0100));
assert_eq!((hovered.target, hovered.guid), (None, None));
assert!(!hovered.refused);
assert_eq!(hovered.distance, f32::MAX);
}
/// A living Orc creature whose `UNIT_FIELD_FLAGS` are `flags`, with its `UNIT_FIELD_CHARMEDBY`
/// (10) and `UNIT_FIELD_CREATEDBY` (14) the guids `charmed_by` and `created_by`, 0 for none.
fn owned(flags: u32, charmed_by: u32, created_by: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(35, 2),
(46, flags),
(22, 100),
(10, charmed_by),
(11, 0),
(14, created_by),
(15, 0),
]))
}
/// The word's plain unit bit (`0x0002`), which `0x6e61a0` takes the player for (`0x850e`), and
/// the enemy bit (`0x0080`), which it does not.
const SELF_WORD: u16 = 0x0002;
const ENEMY_WORD: u16 = 0x0080;
/// `0x480610`, `48064f`-`480676`: a unit flagged `UNIT_FLAG_POSSESSED` whose charmer, else
/// creator, is the active player is no candidate outside targeting, or while a word without
/// `0x20` stands, so the pick passes through it to the unit behind. Us is guid 1.
#[test]
fn a_unit_you_possess_lets_the_pick_through_to_the_one_behind() {
let possessed = crate::target::UNIT_FLAG_POSSESSED;
let cases: [(&str, ObjectStore); 3] = [
(
"a Mind Controlled unit, charmed by us",
owned(possessed, 1, 0),
),
("an Eye of Kilrogg, created by us", owned(possessed, 0, 1)),
(
"charmed by us, created by another: the charmer comes first",
owned(possessed, 1, 7),
),
];
for (what, front_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
let rear = unit_at(&mut world, 11, 0.0, foe());
for word in [None, Some(ENEMY_WORD)] {
let hovered = pick_under(&mut world, word);
assert_eq!(
hovered.target,
Some(rear),
"{what}: the rear, word {word:?}"
);
assert_eq!(hovered.guid, Some(11), "{what}: word {word:?}");
}
// The word that takes us takes it too: the exclusions are skipped (`48062c`).
assert_eq!(
pick_under(&mut world, Some(SELF_WORD)).target,
Some(front),
"{what}: while flag 0x20 stands"
);
assert_eq!(
pick_under(&mut world, None).target,
Some(rear),
"{what}: and excluded again once the word ends"
);
}
}
/// With nothing behind it, the unit you possess leaves the pick empty, which is no refusal.
#[test]
fn a_lone_unit_you_possess_is_no_pick_at_all() {
let mut world = unit_pick_world();
unit_at(
&mut world,
10,
0.0,
owned(crate::target::UNIT_FLAG_POSSESSED, 1, 0),
);
let hovered = pick_under(&mut world, None);
assert_eq!((hovered.target, hovered.guid), (None, None));
assert!(!hovered.refused);
}
/// The exclusion asks for the flag, then for us: an owned unit that is not possessed, a unit
/// someone else possesses, and one whose charmer is another even where its creator is us, all
/// stay candidates.
#[test]
fn a_unit_you_do_not_possess_keeps_the_pick() {
let possessed = crate::target::UNIT_FLAG_POSSESSED;
let cases: [(&str, ObjectStore); 5] = [
("possessed, charmed by another", owned(possessed, 2, 0)),
("possessed, created by another", owned(possessed, 0, 2)),
(
"possessed, charmed by another, created by us: the charmer comes first",
owned(possessed, 2, 1),
),
(
"our own pet, created by us but not possessed",
owned(0, 0, 1),
),
("possessed by no one", owned(possessed, 0, 0)),
];
for (what, front_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
unit_at(&mut world, 11, 0.0, foe());
for word in [None, Some(ENEMY_WORD), Some(SELF_WORD)] {
assert_eq!(
pick_under(&mut world, word).target,
Some(front),
"{what}: word {word:?}"
);
}
}
}
/// `0x481116`: only a word with unit bits (`0x878e`) puts units and players in the pick. A
/// ground word, a lock word and a bag-item word pick none, so a unit in front of the ground
/// or the chest is no obstacle, and a unit word does pick them.
#[test]
fn only_a_word_with_unit_bits_picks_units() {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, friend());
for word in [0x0040, 0x0020, 0x4800, 0x0800, 0x0010] {
let hovered = pick_under(&mut world, Some(word));
assert_eq!(hovered.target, None, "{word:#06x}");
assert!(!hovered.refused, "{word:#06x}");
}
for word in [0x0002, 0x0100] {
assert_eq!(
pick_under(&mut world, Some(word)).target,
Some(front),
"{word:#06x}"
);
}
}
/// The press latches the hover's pick, so the click reaches what the cursor showed: the unit
/// behind, not the one in front the word refuses.
#[test]
fn the_press_latches_the_pick_the_word_left() {
use crate::target::{latch_press_pick, PressPick};
let mut world = unit_pick_world();
unit_at(&mut world, 10, 5.0, foe());
let rear = unit_at(&mut world, 11, 0.0, friend());
world.init_resource::<HoveredObject>();
world.init_resource::<PressPick>();
world.init_resource::<crate::target::WorldCursor>();
world.init_resource::<crate::target::cursor_mode::AttackFork>();
world.init_resource::<ButtonInput<MouseButton>>();
pick_under(&mut world, Some(0x0100));
world
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Left);
world
.run_system_cached(latch_press_pick)
.expect("the press latches");
assert_eq!(world.resource::<PressPick>().hovered.target, Some(rear));
}
/// 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"
);
}
}