Files
benilla-octo/crates/benilla-app/src/target/hover_probe.rs
T
Sam SungandClaude Opus 5.5 d8f4f31ea1 Comments in benilla-app, the player, entities, animation and targeting: one line of fact each, no history, no record numbers (#328)
Part of #41: the third of five `benilla-app` pieces. This one is the player, the entities, animation and targeting: the mover, the camera, movement on the wire, the body and creature animation drivers, spell visuals, equipment and corpses, and the target, cursor and by-name layers. Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 26,066 comment lines become 9,042.

Fixes #104. Fixes #108. Fixes #109.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - The follow gate's `0x606ba0` is `CanCooperate` (a faction-group-mask test), not `CanAssist` (`0x6066f0`).
  - The facing report is sent from the move-command drain (`0x615fc6`); `0x617100`/`0x617170` are the transport-GUID heartbeat.
  - vmangos turns a corpse into bones under the corpse's own guid (`Map.cpp:3608-3649`), and the bones get new flags and zeroed item slots.
  - Two claims about the item models are refuted by the shipped data: not every helm stem ships all sixteen race and sex files, and some item emitters sit under a keyed billboard chain.
  - Docs sitting on the wrong item, stale function names, and disassembler labels, which become client addresses.
  - vmangos line citations are checked against the pinned vmangos revision.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour, or one whose reference side is untraced, says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the shipped data, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#293, #294, #301, #308, #316 and #317 among them). The comments here state what the code does. String literals are untouched; they are #92.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:06:42 +07:00

229 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The headless hover probe: aims the mouseover pick from a screen point when the window has no OS
//! cursor, and reports what the pick found. An unattended run's window never receives the OS
//! cursor, so without it [`super::hover::update_hovered_object`] never reaches the pick.
//!
//! `WOW_HOVER_PROBE` names the aim, in the window's cursor space (logical px, y-down):
//!
//! | value | aim |
//! |---|---|
//! | `centre` / `center` | the window's middle |
//! | `<x>,<y>` | that point |
//! | `sweep` | a fixed 7×5 grid over the middle half of the window, one point per frame, cycling |
//! | `lock` | sweep until something is picked, then hold that object for the rest of the run |
//!
//! `lock` stands for a resting pointer: it holds the hit point in the object's own frame and
//! re-projects it every frame, so it follows camera drift and the object's motion, and it never
//! releases. A sweep re-enters its object every cycle, and a re-enter rebuilds the mouseover plate,
//! so only `lock` can show a defect in a plate that is already up.
//!
//! Armed only while the window reports no cursor, so a person's pointer always wins.
use bevy::prelude::*;
use bevy::window::Window;
/// The aim, parsed once per process.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Aim {
Centre,
At(i32, i32),
Sweep,
Lock,
}
fn aim() -> Option<Aim> {
static AIM: std::sync::OnceLock<Option<Aim>> = std::sync::OnceLock::new();
*AIM.get_or_init(|| {
let raw = std::env::var("WOW_HOVER_PROBE").ok()?;
let v = raw.trim();
match v {
"centre" | "center" => Some(Aim::Centre),
"sweep" => Some(Aim::Sweep),
"lock" => Some(Aim::Lock),
_ => match v.split_once(',') {
Some((x, y)) => match (x.trim().parse(), y.trim().parse()) {
(Ok(x), Ok(y)) => Some(Aim::At(x, y)),
_ => {
warn!(
"hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep`, \
`lock` or `x,y`"
);
None
}
},
None => {
warn!(
"hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep`, `lock` or \
`x,y`"
);
None
}
},
}
})
}
/// Whether the probe is armed: one `OnceLock` read, cheap per frame.
pub(super) fn armed() -> bool {
aim().is_some()
}
/// The point to pick from this frame; the caller uses it only where the real cursor is absent.
pub(super) fn point(window: &Window, frame: u64) -> Option<Vec2> {
let (w, h) = (window.width(), window.height());
match aim()? {
Aim::Centre => Some(Vec2::new(w / 2.0, h / 2.0)),
Aim::At(x, y) => Some(Vec2::new(x as f32, y as f32)),
// 7 × 5 cells over the middle half, off the frame's edges (chrome, sky, the player's own
// back), one per frame. Once `lock` holds an object, `LockedAim::point` answers first.
Aim::Sweep | Aim::Lock => {
const COLS: u64 = 7;
const ROWS: u64 = 5;
let cell = frame % (COLS * ROWS);
let (cx, cy) = (cell % COLS, cell / COLS);
let fx = 0.25 + 0.5 * (cx as f32 / (COLS - 1) as f32);
let fy = 0.25 + 0.5 * (cy as f32 / (ROWS - 1) as f32);
Some(Vec2::new(w * fx, h * fy))
}
}
}
/// Whether the aim holds its first pick (`lock`).
pub(super) fn locks() -> bool {
matches!(aim(), Some(Aim::Lock))
}
/// This frame's aim, published once by the pick so the UI mouse feed and the tooltip's
/// cursor-seated arm agree with it about where the pointer is. A process global, like the env var
/// behind it: its readers are systems already at Bevy's parameter ceiling.
static AIM_NOW: std::sync::Mutex<Option<Vec2>> = std::sync::Mutex::new(None);
/// Called by the pick before its own gates: a frame that skipped the pick still reports a pointer,
/// or the UI feed would drop it and unlatch the gate that skipped it.
pub(super) fn publish(point: Option<Vec2>) {
*AIM_NOW.lock().unwrap_or_else(|e| e.into_inner()) = point;
}
/// This frame's published aim.
pub(super) fn now() -> Option<Vec2> {
*AIM_NOW.lock().unwrap_or_else(|e| e.into_inner())
}
/// `lock`'s held target: the part first picked, and the hit point in that part's own frame.
#[derive(Default)]
pub(super) struct LockedAim {
held: Option<(Entity, Vec3)>,
}
impl LockedAim {
/// The held point on screen now; `None` while nothing is held or it is off-camera, and the grid
/// searches.
pub(super) fn point(
&self,
camera: &Camera,
cam_tf: &GlobalTransform,
transform_of: impl Fn(Entity) -> Option<GlobalTransform>,
) -> Option<Vec2> {
let (part, local) = self.held?;
let gt = transform_of(part)?;
camera
.world_to_viewport(cam_tf, gt.transform_point(local))
.ok()
}
/// Holds the first hit and never another. The caller gates on the mode, which keeps this
/// testable without the env var.
pub(super) fn hold(&mut self, part: Entity, gt: &GlobalTransform, hit_world: Vec3) {
if self.held.is_some() {
return;
}
let local = gt.affine().inverse().transform_point3(hit_world);
info!("hover probe: LOCKED on {part} at its own {local:?} — the aim holds here now");
self.held = Some((part, local));
}
}
/// One log line per change: what the pick found, and the GameObject tooltip ladder's terms
/// (eligibility, the published mouseover, the ask-once template).
#[derive(Default)]
pub(super) struct ProbeReport {
last: Option<String>,
}
impl ProbeReport {
pub(super) fn say(&mut self, line: String) {
if self.last.as_deref() == Some(line.as_str()) {
return;
}
info!("hover probe: {line}");
self.last = Some(line);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sweep_grid_stays_in_the_middle_half() {
let w = 800.0_f32;
let h = 600.0_f32;
for cell in 0..35_u64 {
let (cx, cy) = (cell % 7, cell / 7);
let fx = 0.25 + 0.5 * (cx as f32 / 6.0);
let fy = 0.25 + 0.5 * (cy as f32 / 4.0);
let (x, y) = (w * fx, h * fy);
assert!((w * 0.25..=w * 0.75).contains(&x), "col {cx} at {x}");
assert!((h * 0.25..=h * 0.75).contains(&y), "row {cy} at {y}");
}
}
#[test]
fn the_lock_holds_its_first_target_in_the_objects_own_frame() {
let mut l = LockedAim::default();
let first = Entity::from_raw_u32(1).expect("a valid test entity id");
let later = Entity::from_raw_u32(2).expect("a valid test entity id");
// A part standing 10 yd east; the ray landed 2 yd up its face.
let gt = GlobalTransform::from_translation(Vec3::new(10.0, 0.0, 0.0));
l.hold(first, &gt, Vec3::new(10.0, 2.0, 0.0));
assert_eq!(
l.held,
Some((first, Vec3::new(0.0, 2.0, 0.0))),
"the hit is stored relative to the part, not in world space"
);
l.hold(later, &gt, Vec3::ZERO);
assert_eq!(
l.held.map(|(e, _)| e),
Some(first),
"a later pick never steals the lock"
);
}
#[test]
fn an_unheld_lock_aims_at_nothing() {
let l = LockedAim::default();
let cam = Camera::default();
assert!(l
.point(&cam, &GlobalTransform::IDENTITY, |_| None)
.is_none());
}
#[test]
fn the_published_aim_round_trips() {
publish(Some(Vec2::new(3.0, 4.0)));
assert_eq!(now(), Some(Vec2::new(3.0, 4.0)));
publish(None);
assert_eq!(now(), None);
}
#[test]
fn the_report_only_speaks_on_change() {
let mut r = ProbeReport::default();
r.say("a".into());
assert_eq!(r.last.as_deref(), Some("a"));
r.say("a".into());
assert_eq!(r.last.as_deref(), Some("a"));
r.say("b".into());
assert_eq!(r.last.as_deref(), Some("b"));
}
}