Files
benilla-octo/crates/benilla-app/src/target/hover.rs
T
SamandClaude Fable 5 a8a7d85ca2 six lanes stop paying for the idle crowd, and Print Screen lands
- Print Screen takes a screenshot to `benilla-config/Screenshots/` (F13 on macOS), shows the reference's "Screen Captured" line, and the line never appears in the file — the capture defers one frame so the hidden confirmation's quads rebuild first; and the rule it surfaced is now hard law: benilla never writes anything into the WoW install
- six per-frame taxes now skip when their inputs are settled: water foam asks a spatial index (a dry unit is one hash miss, was a ~2.2k-surface scan per unit), an idle remote stops its capsule sweeps at the fixed point, hover picks stop allocating ahead of the ray-sphere reject, an unchanged unit's loadout is not re-derived, and the ground-shade walk stops re-asserting the world — settled inputs produce bitwise-identical outputs, each lane fenced by its own test; ms pricing still waits on a quiet machine
- overhead names take the V-plates' 20-yard cap, read at the bytes (this one is wrong and reverts in the next snapshot — the law belongs to the other nameplate system)
- the calendar lifts out of the `date()` global into its own module

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:04:55 +07:00

563 lines
29 KiB
Rust

//! The mouseover pick — which unit is under the cursor, recomputed each frame into
//! [`super::Hovered`] the way the real client finds it (wow-re pick-volume RE `bd630be` +
//! `31562f1d`): a **broad phase** ray-vs-sphere on the current animation's bounds, then a
//! **narrow phase** ray-vs-triangle against the unit's **posed render mesh**, with the
//! reference's generous +1-model-unit halo retry when nothing hits exactly. The selection /
//! interaction story that consumes the verdict lives in the [`super`] module doc.
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::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::CameraControl;
use crate::ui_script::PointerOverUi;
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-occlusion distance** (the reference's `CWorld::Intersect` leg of the
/// scene trace `0x480df0` — wow-re selection-circle PART 3, §5-cross-checked 2026-07-20): one
/// physics ray from the cursor through the [`PickOccluder`] set (terrain, the WMO walk-bake faces
/// ≈ the byte-decoded `0x84` reject-mask, static doodad hulls). Both object picks below run
/// **unbounded** and post-compare against it — the reference discards the object hit iff the world
/// hit is *strictly* nearer (`0x480eb4`: tie keeps the object). No cursor / mouselook →
/// `INFINITY` (the picks bail on their own guards). The occluder set deliberately excludes net
/// entities (units, GameObject hulls, transports) — they are the *object* trace; a chest must not
/// occlude itself.
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,
// Default (terrain/doodads) + the WMO *walk* bake — the mask that reaches every
// `PickOccluder`-marked collider; the marker picks the exact occluder 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);
}
}
/// Recompute the unit under the cursor each frame — the real client's two-phase pick (wow-re
/// pick-volume RE `bd630be` + `31562f1d`): **broad** = the cursor ray vs the *current animation's*
/// bounds sphere (world-placed + world-scaled, no pad); **pass 1** = the ray vs the unit's **posed
/// render mesh** (each drawn vertex skinned through the live joint pose, per-triangle), nearest
/// world-distance hit wins; **pass 2, only when pass 1 hit nothing anywhere** (the mouse pick's
/// generous retry — never LoS/spell traces): the same posed mesh with every vertex displaced
/// **+1 model-unit along its skinned normal** (a ~1-yd halo, fattest where the body is widest),
/// resolved by the reference's priority ladder — last frame's pick sticks (anti-flicker), else an
/// alive unit beats a dead one even when farther, ties by distance. A unit with no skinned parts
/// (cube fallback) keeps the interim render-mesh AABB test, competing at pass-1 level. Inert while
/// mouse-looking (cursor hidden) or over the dev UI.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn update_hover(
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
window: Query<&Window, With<PrimaryWindow>>,
rig: Res<CameraControl>,
pointer_over_ui: Res<PointerOverUi>,
// The frame's world-occlusion distance: the final pick is discarded iff the world hit is
// strictly nearer (behind terrain/WMO/doodad geometry — the post-hoc compare below).
occlusion: Res<PickOcclusion>,
// The V-plates' screen rects (last frame's layout — the plate drive runs after this chain).
plate_rects: Res<crate::vplates::PlateRects>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
// The owned palette table (decision 0720): the picker reads the same world-space matrices
// the vertex stage skins with, straight from the CPU rows.
palettes: Res<benilla_world::rig_palette::RigPalettes>,
rigs: Query<&benilla_world::rig_palette::RigSkin>,
// Last frame's pick, for pass 2's sticky-hover (the reference's anti-flicker cache: the previous
// pick outranks everything in the halo retry, so the hover doesn't strobe between two units).
mut last_pick: Local<Option<Entity>>,
// Unit roots: pose + scale, the playing animation (for its bounds sphere), the descriptor store
// (alive-vs-dead sets the pass-2 priority), the part children — and whether the body is drawn
// at all. **You cannot click what is not in the scene** (decision 1277): a body the
// exterior-scene election sent to pass 2 never reaches the reference's draw list, so it takes
// no mouseover, no sword cursor and no click. This lane used to ray-test every unit root with a
// `Guid` and never ask, which is how the director could still target Tanaris mobs through a
// sealed cavern ceiling after their models had correctly stopped drawing.
roots: Query<
(
Entity,
&GlobalTransform,
&NetEntity,
Option<&ModelAnimations>,
Option<&AnimDriver>,
Option<&ObjectStore>,
Option<&Children>,
Option<&crate::entities::mount::MountChild>,
Option<&InheritedVisibility>,
),
(With<Guid>, Without<SelfPlayer>),
>,
// A mounted unit's mount-child part children (decision 0441): the mount's geometry joins the
// unit's pick set — the reference draws mount + rider as one clickable unit.
child_sets: Query<&Children>,
// A part child's mesh + its palette-rig link (the rig its vertices pose through).
parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>,
// Fallback path: a unit's pickable mesh children, by [`CreaturePickPart`] (stamped beside the
// part's `WorldObject` at the attach sites, fallback cube included — the archetype filter that
// replaced a per-frame kind-compare over every streamed row) — model-local `Aabb`, world
// transform, and the link to the streamed parent (whose `Guid` we resolve the hit to).
meshes: Query<
(
&ChildOf,
&Aabb,
&GlobalTransform,
Option<&InheritedVisibility>,
),
With<CreaturePickPart>,
>,
units: Query<&Guid, Without<SelfPlayer>>,
) {
hovered.target = None;
hovered.guid = None;
hovered.distance = f32::MAX;
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;
};
// The plate is mouse-enabled UI on the reference: the cursor inside a plate's rect makes
// that plate's unit the mouseover (OnEnter `0x7cb850` → `[0xb4e2c8]`) and the frame walk
// stops there — no world pick behind it. So plate hover brightens the model, lights the
// plate bar, and click-selects, exactly like hovering the body. Reverse order = the later-drawn
// (visually topmost) plate wins an overlap.
if let Some(&(_, entity)) = plate_rects.0.iter().rev().find(|(r, _)| r.contains(cursor)) {
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|g| g.0);
hovered.distance = 0.0; // a plate is topmost UI — it beats any world GameObject at a tie
*last_pick = Some(entity);
return;
}
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
let (origin, dir) = (ray.origin, *ray.direction);
let limit = occlusion.distance;
// ── The faithful path: collect broad-sphere-passing candidates, then pass 1 / pass 2 ────────
// A pass-2-eligible candidate: the unit, its pass-2 priority (alive 3 / dead 2 — the lootable
// refinement awaits loot state), its parts' mesh ids, and its posed joint palette.
let mut candidates: Vec<(Entity, u8, Vec<AssetId<Mesh>>, Vec<Mat4>)> = Vec::new();
// Units the faithful path *owns* (they have skinned parts): excluded from the AABB fallback
// even when the broad phase rejects them — the reference wouldn't click them there either.
let mut faithful: HashSet<Entity> = HashSet::new();
for (entity, gt, net, anims, drv, store, children, mount_child, drawn) in &roots {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
// Not drawn ⇒ not clickable (see the query's note). Deliberately BEFORE `faithful.insert`
// below, so an undrawn unit is not claimed by the faithful path either — otherwise it
// would merely be excluded from the AABB fallback and stay unpickable by accident rather
// than by rule.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let Some(children) = children else { continue };
// Ownership probe only — no collect yet. The `skinned` Vec used to be built HERE, for
// every drawn unit in the scene, ahead of the few-FLOP broad phase that rejects nearly
// all of them (1473 §3's hover row); the survivors alone pay it now, below. Membership
// in `faithful` must still be decided pre-reject — a broad-phase-rejected skinned unit
// stays excluded from the AABB fallback by RULE, not by accident.
if !children.iter().any(|c| parts.contains(c)) {
continue; // static/cube unit → the AABB fallback below
}
faithful.insert(entity);
// Broad phase: the playing sequence's bounds sphere, world-placed + world-scaled (uniform
// scale — the root bakes `OBJECT_FIELD_SCALE_X`). An unknown clip / unauthored radius passes
// through (the reference falls back to the header sphere; permissive is the safe direction).
// A MOUNTED unit passes through too: the rider's Mount-pose sphere doesn't cover the
// mount's body (the reference recomputes the pick radius from the mount's box — a P2
// refinement, decision 0441).
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();
// Ray-sphere: reject when the ray's closest approach to the centre exceeds the radius
// (dir is normalized; a centre behind the origin projects to t=0, keeping units we
// stand inside clickable).
let along = (centre - origin).dot(dir).max(0.0);
if (centre - origin - dir * along).length_squared() > radius * radius {
continue;
}
}
let skinned: Vec<_> = children.iter().filter_map(|c| parts.get(c).ok()).collect();
// The posed joint palette (parts of one skeleton share the rig): the same
// world-from-bind-pose matrices GPU skinning applies, read from the owned palette rows
// (decision 0720 — last frame's propagated pose, exactly what the previous
// joint-GlobalTransform read gave).
let palette_of =
|sk: &[(&Mesh3d, &benilla_world::rig_palette::RigPart)]| -> Option<Vec<Mat4>> {
sk.first().and_then(|(_, part)| {
let rig = rigs.get(part.0).ok()?;
palettes.world_palette(rig.slot, rig.bones() as usize)
})
};
let Some(palette) = palette_of(&skinned) else {
continue;
};
let mesh_ids = skinned.iter().map(|(m, _)| m.id()).collect();
let priority = if store.is_some_and(|s| s.0.unit_is_dead()) {
2
} else {
3
};
candidates.push((entity, priority, mesh_ids, palette));
// The mount child's parts join as a second candidate resolving to the SAME unit
// (decision 0441): its own skeleton, its own palette, one hoverable whole.
if let Some(mc) = mount_child {
if let Ok(mc_children) = child_sets.get(mc.0) {
let mount_skinned: Vec<_> = mc_children
.iter()
.filter_map(|c| parts.get(c).ok())
.collect();
if let Some(mount_palette) = palette_of(&mount_skinned) {
let mount_mesh_ids = mount_skinned.iter().map(|(m, _)| m.id()).collect();
candidates.push((entity, priority, mount_mesh_ids, mount_palette));
}
}
}
}
// Pass 1 — the exact posed render mesh, pure nearest-wins (the reference's first pass).
// UNBOUNDED, like the reference: the object trace never carries the world distance; occlusion
// is one strict compare on the final 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));
}
}
}
// ── Fallback for skinless units only: the model-bounds box test (pre-RE interim) ────────────
// A unit's parts are separate meshes; testing each and keeping the closest parent is equivalent
// to testing the union. `dir` is normalized, so `t` is a world distance like the hits above.
for (child_of, aabb, gt, drawn) in &meshes {
// …and the same rule on the skinless fallback: a part inherits its root's pass-2 verdict.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let parent = child_of.parent();
if faithful.contains(&parent) || units.get(parent).is_err() {
continue; // posed-mesh-tested above, or not a targetable unit (e.g. the self player)
}
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 — nothing exactly hit: the reference's generous retry (mouse-pick only). Same
// candidates, every vertex displaced +1 model-unit along its skinned normal — a ~1-yd halo
// around the drawn body. Accept by the priority ladder, not pure distance: last frame's pick
// sticks (anti-flicker), else higher priority wins even when farther, ties by 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));
}
// The occlusion verdict — the reference's single strict compare (`0x480df0` @ `0x480eb4`):
// the object hit is discarded iff the world hit is STRICTLY nearer (a tie keeps the object).
if best.is_some_and(|(t, _)| limit < t) {
best = None;
}
if let Some((t, entity)) = best {
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|g| g.0);
hovered.distance = t;
}
*last_pick = best.map(|(_, e)| e);
}
/// Recompute the **GameObject** under the cursor each frame into [`HoveredObject`] (decision 0236):
/// a mesh-accurate ray pick against GameObject parts *only*, reusing the inspector's picker
/// (the resident-geometry caster of decision 0857, which hits the colliderless props a physics ray
/// misses and the `RENDER_WORLD`-only static forms Bevy's `MeshRayCast` lost at 0834). Kept
/// separate from [`update_hover`]'s unit pick because a GameObject is usable-but-not-*selected*:
/// this drives the Interact cursor and the right-click USE, never selection. Inert while
/// mouse-looking (cursor hidden) or over the dev UI, exactly like the unit pick. Cheap — only the
/// handful of GO parts on screen are in the pick set.
///
/// **Two passes, like the unit pick** (decision 1071 — GameObjects are the same type-1 candidates
/// as units in the reference's resolve `0x7089c0`, wow-re object-layer mouse-pick): **pass 1** =
/// the exact resident mesh, pure nearest-wins; **pass 2, only when pass 1 hit nothing anywhere**
/// (the mouse pick's generous retry): the same geometry with every vertex displaced +1 model-unit
/// along its authored normal — a ~1-yd halo, which is what makes a wispy herb clickable *around*
/// its leaves instead of only pixel-on-texture. "Nothing anywhere" spans the unit pick too (one
/// resolve in the reference): a frame where [`Hovered`] holds any unit — exact hit or its own
/// halo — never opens the GO halo, because the pass-2 accept ladder ranks every unit (alive 3 /
/// dead 2) above every GameObject (highlightable 1 / else 0). Within the GO halo the same ladder
/// applies: last frame's pick sticks (anti-flicker), else higher priority wins even when farther,
/// ties by distance. Both passes stay world-occlusion-clamped. (Residual, same slice as the
/// eligibility note below: the reference's sticky slot is cross-type, so a stuck GO pick can
/// outrank a unit's halo there; our split picks give the unit that frame instead.)
/// The GO picker's own state, bundled — the pick-set cache with the stream edges that rebuild
/// it, plus the sticky-hover memory — one [`SystemParam`] because [`update_hovered_object`]
/// sits at Bevy's 16-param function-system ceiling. See the maintenance comment at the top of
/// that system for the cache contract.
#[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 GO pick, for pass 2's sticky-hover (the reference's anti-flicker cache, by
/// net entity — the same rung the unit picker keeps).
last_pick: Local<'s, Option<Entity>>,
}
#[allow(clippy::too_many_arguments)]
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's verdict — [`update_hover`] runs earlier in the target chain. Any unit hover
// means pass 1 + the unit halo already own the mouseover, so the GO pass 2 stays shut.
unit_hovered: Res<Hovered>,
mut hovered: ResMut<HoveredObject>,
// Every pickable GameObject part carries [`GoPickPart`] (stamped beside its
// `WorldObject { kind: GameObject }` at the attach sites; units/doodads/WMOs never enter this
// query — units have their own posed-mesh pick).
go_parts: Query<Entity, With<GoPickPart>>,
child_of: Query<&ChildOf>,
guids: Query<&Guid>,
stores: Query<&ObjectStore>,
// GENERIC's eligibility is its template's `data[1]` (decision 0762) — the ask-once cache.
go_templates: Res<crate::go_templates::GameObjectTemplates>,
// The faction term of eligibility (decision 0764): the GO's own template reaction toward us.
factions: Option<Res<super::ring::Factions>>,
self_q: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
parts: PickParts,
// The picker's own state, one bundled param ([`GoPickSet`] — the fn sits at Bevy's
// 16-param ceiling): the pick-set cache, its stream edges, and the sticky-hover memory.
mut cache: GoPickSet,
) {
let added_parts = &cache.added;
let removed_parts = &mut cache.removed;
let pickable = &mut *cache.cache;
let last_pick = &mut *cache.last_pick;
// The pick set (bevy's `HashSet` — the caster's type, not the `std` one this module uses
// elsewhere), rebuilt on GO-part stream edges ONLY — the `WaterIndex` shape. It used to be
// collected from every streamed GO part on every cursor frame (ChildOf hop + store probe +
// hash insert each); membership only actually changes when GO models spawn or despawn, and
// a GO's type id never changes after create. Maintained FIRST, ahead of every early return:
// an `Added` edge is only visible on the frame it happens (the run advances this system's
// change tick even through a `return`), so a rebuild parked behind the cursor gates would
// silently drop parts streamed in while the pointer sat on UI. A despawned entry between
// edges self-filters at the caster (`parts.get` on a dead entity misses).
//
// A transport-family GO (TRANSPORT 11 / MAP_OBJECT 14 / MO_TRANSPORT 15) never joins the
// set: in the reference it has no pick geometry at all — the GO model resolver `0x5f80e0`
// returns 0 for these three types (they render via the WMO/spline path, not an M2) and their
// strategy's interaction predicates are constant-false (w2c1 + go-render-gate + the +0x14
// vtable dump). Concretely: a ship's hull must not swallow the pick — an NPC on deck stays
// hoverable through the railing, and the hull shows no gear or tooltip.
if removed_parts.read().next().is_some() || !added_parts.is_empty() {
pickable.clear();
pickable.extend(go_parts.iter().filter(|&e| {
let root = child_of.get(e).map_or(e, |c| c.parent());
!stores
.get(root)
.is_ok_and(|s| matches!(s.0.gameobject_type_id(), 11 | 14 | 15))
}));
}
hovered.target = None;
hovered.guid = None;
hovered.distance = f32::MAX;
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;
};
if pickable.is_empty() {
*last_pick = None;
return;
}
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
// The hit is a mesh part; its GameObject net entity (which carries the `Guid`) is one `ChildOf`
// hop up — the attach convention (the same one `update_hover`'s fallback walks). Guard the rare
// case the pickable itself is the guid-bearing entity.
let resolve_net = |e: Entity| {
if guids.contains(e) {
e
} else {
child_of.get(e).map_or(e, |c| c.parent())
}
};
let self_store = self_q.single().ok();
// Pass 1 — the exact resident geometry, pure nearest-wins (priority-independent).
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 — nothing exactly hit anywhere (no GO, and no unit either — the doc above): the
// generous retry over the normal-inflated geometry, accepted by the priority ladder.
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 {
// The classify priority (`0x480c90`): highlightable GameObject 1, else 0. A store
// that hasn't streamed reads highlightable — the eligibility gate's own
// permissive default, so a fresh spawn isn't a dead zone for its first frames.
stores.get(net).map_or(1, |s| {
let reaction = crate::target::cursor_mode::go_reaction(
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_templates.get(g)?.meeting_stone_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));
}
// The occlusion verdict (`0x480df0` @ `0x480eb4`): discard the GO hit iff the world hit is
// STRICTLY nearer — a tie keeps the object.
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 {
return;
};
let Ok(guid) = guids.get(net_entity) else {
return;
};
// **The mouseover-eligibility gate** (`[obj->vtbl+0x54]` at `0x482982`, decision 0762): an
// ineligible object publishes the NULL mouseover, so it gets no tooltip, no +64 brighten and no
// cursor — nothing. Applied here, at the one place the GO mouseover is published, so all three
// consumers fall out together exactly as they do in the reference (which reaches `0x492890`,
// `0x52aa20` and `0x4945e0` only past this gate).
//
// KNOWN RESIDUAL: the reference nulls the **whole** mouseover, so a unit standing behind an
// ineligible portcullis is not hoverable through it either. benilla picks unit and GameObject
// separately and arbitrates by distance, so here the unit behind it stays hoverable. Narrower
// than the old behaviour (which tooltipped the portcullis itself) and documented rather than
// silently accepted; closing it wants the single-pick arbitration, which is its own slice.
if let Ok(store) = stores.get(net_entity) {
let tmpl = go_templates.get(guid.0);
let reaction = crate::target::cursor_mode::go_reaction(
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_area),
),
},
) {
return;
}
}
hovered.target = Some(net_entity);
hovered.guid = Some(guid.0);
hovered.distance = distance;
}