diff --git a/crates/benilla-app/src/collision/one_sided.rs b/crates/benilla-app/src/collision/one_sided.rs index e67f14e5..50b4b63c 100644 --- a/crates/benilla-app/src/collision/one_sided.rs +++ b/crates/benilla-app/src/collision/one_sided.rs @@ -507,6 +507,84 @@ fn aabb_to_local(aabb: ColliderAabb, pos: Vec3, inv_rot: Quat) -> ParryAabb { ParryAabb::new(mins, maxs) } +/// One collision triangle near a point, as the **movement law** sees it — the geometry readout +/// behind the step-up probe ([`crate::player::step_probe`]). +pub(crate) struct FaceProbe { + /// The collider it belongs to (a WMO walk bake, a terrain tile, a doodad hull…). + pub(crate) entity: Entity, + /// World-space normal at the **authored winding** — the vector [`cast_move`]'s gate tests. + pub(crate) normal: Vec3, + /// World-space vertices. + pub(crate) verts: [Vec3; 3], +} + +impl FaceProbe { + /// Whether this face may block a sweep heading `dir` — [`cast_move`]'s gate, exactly. + pub(crate) fn blocks(&self, dir: Vec3) -> bool { + self.normal.dot(dir) <= FACING_EPS + } + + /// World centroid — what "how far ahead is this face" is measured from. + pub(crate) fn centroid(&self) -> Vec3 { + (self.verts[0] + self.verts[1] + self.verts[2]) / 3.0 + } +} + +/// Every trimesh face inside the box `at ± half` that `filter` can reach, nearest centroid first. +/// +/// **Diagnostic only, and deliberately UNGATED**: it reports the faces the one-sided law *rejects* +/// alongside the ones it keeps, which is the entire point of having it. "The kerb top is authored +/// downward-facing, so our settle probe passes straight through it" and "the advance was too short +/// to reach the tread" produce the same silent non-step from the outside, and opposite readings +/// here. Convex (non-trimesh) colliders have no per-face winding to report and are skipped — the +/// law leaves them whole-shape too. +pub(crate) fn faces_near( + ms: &MoveAndSlide<'_, '_>, + at: Vec3, + half: Vec3, + filter: &SpatialQueryFilter, + limit: usize, +) -> Vec { + let box_aabb = ColliderAabb { + min: at - half, + max: at + half, + }; + let mut out = Vec::new(); + for entity in ms.spatial_query.aabb_intersections_with_aabb(box_aabb) { + let Ok((collider, pos, rot, layers)) = ms.colliders.get(entity) else { + continue; + }; + if !filter.test(entity, layers.copied().unwrap_or_default()) { + continue; + } + let Some(trimesh) = collider.shape_scaled().as_trimesh() else { + continue; + }; + let inv_rot = rot.0.inverse(); + for tri_id in trimesh + .bvh() + .intersect_aabb(&aabb_to_local(box_aabb, pos.0, inv_rot)) + { + let tri = trimesh.triangle(tri_id); + let Some(n) = tri.normal() else { + continue; + }; + out.push(FaceProbe { + entity, + normal: rot.0 * n, + verts: [tri.a, tri.b, tri.c].map(|v| pos.0 + rot.0 * v), + }); + } + } + out.sort_by(|a, b| { + a.centroid() + .distance_squared(at) + .total_cmp(&b.centroid().distance_squared(at)) + }); + out.truncate(limit); + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index 44358c31..3138ddc2 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -668,6 +668,40 @@ fn debug_panel_ui( } } }); + // The step-up probe's last blocked-frame report (`crate::player::step_probe`). + // On the panel *because a capture has to be steerable*: the director walks into + // the kerb that will not climb and watches the `t=` stamp tick and the ladder + // fill in, instead of finding out afterwards that the probe never fired at the + // spot they meant (method §6 — prove the run before reading the result). + egui::CollapsingHeader::new("Step-up") + .default_open(false) + .show(ui, |ui| { + let (report, at) = crate::player::step_probe::latest(); + if report.is_empty() { + ui.label( + egui::RichText::new( + "no blocked walk frame yet — walk square into the thing \ + that will not step up", + ) + .color(OVERLAY_TEXT_DIM), + ); + return; + } + ui.label( + egui::RichText::new(format!("last fired t={at:.1}s")) + .small() + .color(OVERLAY_TEXT_DIM), + ); + egui::ScrollArea::horizontal() + .id_salt("stepup_report") + .show(ui, |ui| { + for line in report { + ui.label( + egui::RichText::new(line).small().monospace(), + ); + } + }); + }); // Future sections add their own CollapsingHeader here. }); diff --git a/crates/benilla-app/src/interact/mod.rs b/crates/benilla-app/src/interact/mod.rs index 9da5dfde..236f06da 100644 --- a/crates/benilla-app/src/interact/mod.rs +++ b/crates/benilla-app/src/interact/mod.rs @@ -57,16 +57,25 @@ pub struct WorldObject { pub detail: String, } -/// A clean left-*click* in the world — a press + release with no drag. Emitted by -/// [`crate::player::control`], which is the single arbiter of click-vs-drag because it owns the -/// left-drag camera orbit: a left *drag* engages the orbit and emits nothing, a left *click* emits this. -/// World-interaction consumers (target selection) read it instead of re-deciding the gesture; *where* -/// the cursor is is already tracked continuously (the hover pick), so this carries no position. +/// A left *select* gesture in the world. Emitted by [`crate::player::control`] on the button's +/// **release**, when the press satisfied the reference's click predicate — under 200 ms whatever the +/// mouse did, or under 800 ms having turned the camera less than 2.25° of yaw / 2.0° of pitch +/// ([`crate::player::camera::PressGesture`], decision 1122). +/// +/// **A drag emits this too.** Orbit and select are independent in the reference: the press engages +/// the camera look immediately and arms this test alongside it, so a fast flick-and-click orbits the +/// camera *and* selects. This doc comment used to assert the opposite as design — *"a left drag +/// engages the orbit and emits nothing"* — and a reporter quoted it back at us with an A/B against +/// the real client proving it wrong (ledger B226). +/// +/// It carries no position because the *pick* is latched separately, at the press +/// ([`crate::target::PressPick`]) — never the live hover, which is blank by the time a dragged +/// click lands. #[derive(Message, Clone, Copy)] pub struct WorldClick; -/// A clean right-*click* in the world — same arbiter and drag test as [`WorldClick`], for the right -/// button (whose *drag* is the character turn). Vanilla's context action: attack a hostile under the +/// The right button's context gesture — same arbiter and same predicate as [`WorldClick`], for the +/// button whose drag is the character turn. Vanilla's context action: attack a hostile under the /// cursor (later: interact/gossip on a friendly). #[derive(Message, Clone, Copy)] pub struct WorldRightClick; diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 8040a121..3ad88ed2 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -51,6 +51,8 @@ mod probe_look; mod server_ride; mod setup; mod state; +/// The step-up diagnostic probe — the blocked-frame report behind the `stup` trace tag. +pub(crate) mod step_probe; mod swim; mod wire_in; @@ -70,9 +72,10 @@ pub(crate) use follow::{FollowRequest, FollowState}; // what lets this module and the concern modules beside it keep naming them `super::X` unchanged. use state::{ MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP, FALL_FAR_TIME, - GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO, - SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT, - TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, + FOOT_CONE_HEIGHT, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, + RUN_BACK_RATIO, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, + STEP_UP_ADVANCE, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL, + WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, }; // `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision // 0737 — residency releases the hold, not ground contact), which owns the deadline push while the @@ -340,17 +343,20 @@ fn control( (With, Without), >, window: Single<(&mut Window, &mut CursorOptions), With>, - // Clean clicks (press+release, no drag) go out here — left for the target picker, right for the - // context action (attack) — while *drags* engage the camera looks below instead; the third is - // the right button's raw DOWN edge (targeting's cancel, decision 0792). The locals hold - // each button's accumulated drag distance while a press is being classified (`None` = no press - // pending). + // Clicks go out here — left for the target picker, right for the context action (attack) — and + // the third is the right button's raw DOWN edge (targeting's cancel, decision 0792). A press + // engages its camera look *and* arms a click test; the release settles that test on the + // reference's time/travel predicate, so one gesture can orbit and select both (decision 1122). + // The locals hold each button's pending [`camera::PressGesture`] (`None` = no press pending). mut world_clicks: ( MessageWriter, MessageWriter, MessageWriter, ), - mut click_test: (Local>, Local>), + mut click_test: ( + Local>, + Local>, + ), // World context for the mover, bundled into one param (16-param limit): the loaded water // surfaces (swim mode + the buoyant float, see [`swim`]), the armed transports (the // platform-frame carry/attach — decision 0438 phase 2; `Without`s only disjoint the borrows), @@ -473,6 +479,7 @@ fn control( left_click, right_click, invert_pitch, + time.elapsed_secs(), ); // A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on // a right-drag, coupled `face_yaw = cam.yaw`); putting the aim back leaves the camera orbiting diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 37ed8c24..ac939d6b 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -20,10 +20,71 @@ use crate::model_fade::{ use crate::net::SelfPlayer; use crate::terrain::WowModelMaterial; -/// Accumulated cursor motion (logical px) past which a held mouse button becomes a **drag** (camera -/// orbit / character turn) rather than a **click** (left: target select; right: the context attack). -/// Small — a click has near-zero jitter; any real drag crosses it in a frame or two. -const CLICK_DRAG_THRESHOLD: f32 = 4.0; +/// The reference's up-edge click predicate, in **camera degrees and milliseconds** — the whole +/// orbit-vs-select law (decision 1122; wow-re `world-click-drag-arbitration.md`, §5 fan-out +/// 2026-08-08). Verbatim, from `0x514ae0`, which returns 1 = suppress / 0 = dispatch: +/// +/// ```text +/// isClick = elapsed < 200ms +/// || (elapsed < 800ms && yaw_travel < 2.25° && pitch_travel < 2.0°) +/// ``` +/// +/// **A press under 200 ms selects however far the mouse swept.** That arm is the entire bug report +/// (ledger B226): flick the cursor across a mob and click on arrival with the hand still moving, and +/// the reference targets it — the camera has been orbiting since the first motion sample and selects +/// anyway. The two mechanisms are independent and share only the button state. +/// +/// **The orbit has no threshold at all** — it engages on the *down* edge (`0x51491f`, guarded only by +/// the 0→1 held transition) and the first motion sample already turns the camera. benilla deferred it +/// behind 4 px of travel and, worse, *destroyed* the pending click on crossing; there is no such +/// thing in the reference. The travel numbers below gate the **click**, never the orbit. +/// +/// The literals in the binary are 8.0 event-units per axis against `Σ|0.8·Δx|` / `Σ|0.6·Δy|` of raw +/// device counts. Copying those would bind us to the reference's device scaling, which is itself +/// only INFERRED ("1 unit = 1 mouse count" is not established). So we copy the **angle** they mean, +/// which is exact and device-independent: 8.0 event-units is `Σ|Δx| = 10.0` raw ⇒ +/// `cameraYawMoveSpeed·10/800` = **2.25°** of yaw at the shipped default 180; and `Σ|Δy| = 13.333` +/// raw ⇒ `cameraPitchMoveSpeed·13.333/600` = **2.0°** of pitch at the default 90. +/// +/// Per axis, independently, and **accumulated absolute travel** — not net displacement from the +/// press point (`0x514400`: `fabs; fadd; fstp`, no subtraction and no stored origin). A shake that +/// returns to where it started still spends the budget. +const CLICK_HOLD_CEILING: f32 = 0.800; +const CLICK_FREE_WINDOW: f32 = 0.200; +const CLICK_YAW_TRAVEL: f32 = 2.25 * std::f32::consts::PI / 180.0; +const CLICK_PITCH_TRAVEL: f32 = 2.0 * std::f32::consts::PI / 180.0; + +/// A primary button's press, while it is still undecided — the reference's world-input state +/// (`[0xbe1148]`): press instant `+0x14`, and the two independent motion accumulators zeroed at +/// `0x514910`/`0x514913`. Our accumulators are in **radians of camera rotation** rather than device +/// counts, for the reason in [`CLICK_HOLD_CEILING`]'s note. +#[derive(Clone, Copy)] +pub(super) struct PressGesture { + /// Seconds on the app clock when the button went down. + at: f32, + /// Accumulated |Δyaw| and |Δpitch| this press has asked the camera for, radians. + yaw_travel: f32, + pitch_travel: f32, +} + +impl PressGesture { + fn new(now: f32) -> Self { + Self { + at: now, + yaw_travel: 0.0, + pitch_travel: 0.0, + } + } + + /// `0x514ae0`, minimised: the free window first, then the bounded-travel window. + fn is_click(&self, now: f32) -> bool { + let elapsed = now - self.at; + elapsed < CLICK_FREE_WINDOW + || (elapsed < CLICK_HOLD_CEILING + && self.yaw_travel < CLICK_YAW_TRAVEL + && self.pitch_travel < CLICK_PITCH_TRAVEL) + } +} /// Third-person orbit-distance limits (yards). **VERIFIED from `WoW.exe` 5875** (`FUN_005112d0` + /// the camera CVars, wow-re `follow-camera`): max orbit = `cameraDistanceMax × cameraDistanceMaxFactor`, @@ -206,10 +267,16 @@ pub(crate) struct CameraPivot { } /// Mouse-look session state machine — start/stop/hand-off between the two look buttons, cursor -/// grab/stash/restore, and the left/right click-vs-drag tests that emit [`WorldClick`]/ -/// [`WorldRightClick`]. Also applies this frame's accumulated motion as look rotation while a button is -/// held (right-drag syncs the character facing too). Called once per frame from [`super::control`]; -/// `both_buttons` is vanilla's both-button run (steers like a right-drag without its own click test). +/// grab/stash/restore, and the two click tests that emit [`WorldClick`]/[`WorldRightClick`]. Also +/// applies this frame's accumulated motion as look rotation while a button is held (right-drag syncs +/// the character facing too). Called once per frame from [`super::control`]; `both_buttons` is +/// vanilla's both-button run (steers like a right-drag without its own click test). +/// +/// **Orbit and select are independent** (decision 1122): every primary press engages its look +/// session immediately and *also* arms a click test, and the release decides the click on +/// [`PressGesture::is_click`] alone. There is no "promotion" and nothing cancels the click for +/// having moved — the pending click used to be destroyed the moment the cursor crossed a 4 px +/// threshold, which is why a drag could never select (ledger B226). #[allow(clippy::too_many_arguments)] pub(super) fn run_look_session( buttons: &ButtonInput, @@ -224,15 +291,17 @@ pub(super) fn run_look_session( pointer_over_ui: bool, inspect_enabled: bool, // A left press this frame the UI already consumed as a cursor-payload world drop (0216 §3) — - // the left click-vs-drag test below must yield to it exactly as it yields to a UI hover, so - // dropping a held item never also starts a camera orbit. + // the left click test must yield to it exactly as it yields to a UI hover, so dropping a held + // item never also selects. click_consumed: bool, world_click: &mut MessageWriter, world_right_click: &mut MessageWriter, world_right_press: &mut MessageWriter, - left_click: &mut Option, - right_click: &mut Option, + left_click: &mut Option, + right_click: &mut Option, invert_pitch: bool, + // Seconds on the app clock — the press predicate's two time gates are measured against it. + now: f32, ) { // The right button's DOWN edge, before any click-vs-drag classification — the reference's // WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. It belongs @@ -244,37 +313,48 @@ pub(super) fn run_look_session( { world_right_press.write(WorldRightPress); } - // A left+right gesture is a both-button run / camera turn — never a target select. Cancel any - // pending left click-vs-drag test the instant the right button joins in, so releasing out of a - // both-button move never fires a spurious selection click. - if buttons.pressed(MouseButton::Right) { + // A chord — both primaries down — is a both-button run, never a select. The reference kills the + // pending click on the *second* press and refuses to arm a new one while another primary is held + // (`0x514ac1`, `0x51481a`), so neither release of a chord can dispatch. Cancel both tests. + if buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right) { *left_click = None; + *right_click = None; } - // The right button's own click-vs-drag test (vanilla's context click — attack): unlike left, the - // right *look* engages instantly on press (turn must feel immediate), so the test just rides the - // session, accumulating motion; the release decides click vs turn below. A left join (both-button - // run) is never a click. - if let Some(moved) = right_click.as_mut() { - *moved += mouse_motion.delta.length(); - if buttons.pressed(MouseButton::Left) { - *right_click = None; - } + // Both tests just ride their session, accumulating the camera rotation the press has asked for; + // the release decides. The travel is charged from the *input* delta, before the pitch clamp — + // the reference accumulates raw device motion, so a drag pinned at the pitch limit still spends + // its budget. + let dyaw = (mouse_motion.delta.x * LOOK_SENSITIVITY).abs(); + let dpitch = (mouse_motion.delta.y * LOOK_SENSITIVITY).abs(); + for test in [&mut *left_click, &mut *right_click].into_iter().flatten() { + test.yaw_travel += dyaw; + test.pitch_travel += dpitch; } - // Mouse-look start/stop + cursor grab. Right-drag turns the character (press-triggered); left-drag - // orbits the camera (deferred — a left *click* selects a target instead, so the orbit only engages - // once the cursor drags past a threshold). Either hides + locks the cursor in place (so it can't - // drift out of the window while we turn) and restores it where it was on release. A press that - // begins over the debug panel is egui's, not ours. + // Mouse-look start/stop + cursor grab. **Both** buttons engage their look session on the DOWN + // edge — the reference has no deferral and no engage threshold (`0x51491f`), and the first + // motion sample already turns the camera. The click test is a separate rider decided at the + // release, which is exactly what lets one gesture orbit *and* select. Either button hides + + // locks the cursor in place (so it can't drift out of the window while we turn) and restores it + // where it was on release. A press that begins over the debug panel is egui's, not ours. if let Some(active) = rig.look { if !buttons.pressed(active.button()) { - // A right press+release that never turned is vanilla's context *click* (attack the unit - // under the cursor) — emit it as the session ends. A handoff release (left still held) - // never fires: the left join already cancelled the test above. - if active == LookButton::Right { - if let Some(moved) = right_click.take() { - if moved < CLICK_DRAG_THRESHOLD { - world_right_click.write(WorldRightClick); + // The session's button went up: settle its click test against the reference predicate. + // A handoff release (the other button still held) never fires — the chord already + // cancelled both tests above. + let test = match active { + LookButton::Left => left_click.take(), + LookButton::Right => right_click.take(), + }; + if let Some(test) = test { + if test.is_click(now) { + match active { + LookButton::Left => { + world_click.write(WorldClick); + } + LookButton::Right => { + world_right_click.write(WorldRightClick); + } } } } @@ -301,41 +381,28 @@ pub(super) fn run_look_session( // A press over the egui dev UI (the overlaid debug panel, the perf pill) or outside the world // viewport is not ours — this keeps a slider-drag from grabbing the cursor into mouse-look. let world_press = cursor_in_viewport(window, camera) && !pointer_over_ui; + // Right-drag turn. Arms its context-click test too; not armed when left is already down (a + // chord is never a click). if buttons.just_pressed(MouseButton::Right) && world_press { - // Right-drag turn: engage immediately on press, as before — while also arming the - // click-vs-drag test (a clean release becomes the context attack click). Not armed when - // left is already down (a both-button run is never a click). rig.look = Some(LookButton::Right); rig.cursor_stash = window.cursor_position(); cursor_opts.grab_mode = CursorGrabMode::Locked; cursor_opts.visible = false; - *right_click = (!buttons.pressed(MouseButton::Left)).then_some(0.0); - } else if !inspect_enabled { - // Left is deferred into a click-vs-drag test so a clean left *click* can select a target - // (vanilla left-click). A left press begins the test; it engages the camera orbit only once - // the accumulated cursor motion crosses `CLICK_DRAG_THRESHOLD`, and a press+release with no - // drag emits a `WorldClick` for the target picker. While the inspector is armed, left belongs - // to it (its own copy-on-click handler), so the test is skipped and left never orbits. - if buttons.just_pressed(MouseButton::Left) && world_press && !click_consumed { - *left_click = Some(0.0); - } - if let Some(moved) = left_click.as_mut() { - *moved += mouse_motion.delta.length(); - if !buttons.pressed(MouseButton::Left) { - // Released with no drag → a click (the hover pick already knows what's under it). - if *moved < CLICK_DRAG_THRESHOLD { - world_click.write(WorldClick); - } - *left_click = None; - } else if *moved >= CLICK_DRAG_THRESHOLD { - // Dragged past the threshold → promote to the left-orbit look session. - rig.look = Some(LookButton::Left); - rig.cursor_stash = window.cursor_position(); - cursor_opts.grab_mode = CursorGrabMode::Locked; - cursor_opts.visible = false; - *left_click = None; - } - } + *right_click = (!buttons.pressed(MouseButton::Left)).then(|| PressGesture::new(now)); + } else if buttons.just_pressed(MouseButton::Left) && world_press && !inspect_enabled { + // Left-drag orbit — engaged on the press, exactly like right, because the reference + // engages on the press (`0x51491f`). The select is not deferred behind it; it rides + // along and settles at the release. While the inspector is armed left belongs to it + // (its own copy-on-click handler), so neither the orbit nor the test starts. + rig.look = Some(LookButton::Left); + rig.cursor_stash = window.cursor_position(); + cursor_opts.grab_mode = CursorGrabMode::Locked; + cursor_opts.visible = false; + // A press the UI already consumed as a cursor-payload world drop (0216 §3) still orbits + // — the reference's orbit is unconditional on the down edge — but must not also select. + *right_click = None; + *left_click = (!click_consumed && !buttons.pressed(MouseButton::Right)) + .then(|| PressGesture::new(now)); } } @@ -807,6 +874,66 @@ mod tests { use crate::billboard::BillboardCard; use crate::mesh_tag::alpha_bits; + /// A press that has travelled `yaw`/`pitch` **degrees** of camera rotation. + fn press(yaw_deg: f32, pitch_deg: f32) -> PressGesture { + PressGesture { + at: 0.0, + yaw_travel: yaw_deg.to_radians(), + pitch_travel: pitch_deg.to_radians(), + } + } + + /// The report this whole change exists for (ledger B226, decision 1122): **a fast click + /// selects however far the mouse swept.** Under 200 ms the reference asks nothing about + /// motion at all (`0x514ae0`'s first arm, `0x514b24`) — which is the gesture people actually + /// make, flicking the cursor at a mob and clicking on arrival with the hand still moving. + /// benilla used to destroy the pending click after 4 px of travel, so this case never fired. + #[test] + fn a_fast_click_selects_however_far_the_mouse_swept() { + // A whole screen's worth of sweep — orders of magnitude past the travel gate. + let swept = press(90.0, 45.0); + assert!( + swept.is_click(0.199), + "under 200 ms, travel is not consulted" + ); + // And the camera is expected to have orbited through all of it: the two are independent, + // which is the half that makes the reference's gesture possible at all. + } + + /// The second arm: between 200 and 800 ms the travel gate applies, per axis and independently + /// (`0x514ae0`'s `both accums < 8.0` arm). The thresholds are the reference's 2.25° of yaw and + /// 2.0° of pitch — see [`CLICK_HOLD_CEILING`] for why we hold the angle, not the raw literal. + #[test] + fn between_the_windows_a_steady_hand_still_clicks_but_a_drag_does_not() { + assert!(press(2.0, 1.5).is_click(0.5), "inside both travel gates"); + assert!( + !press(2.3, 1.5).is_click(0.5), + "yaw alone spends the budget" + ); + assert!(!press(2.0, 2.1).is_click(0.5), "pitch alone spends it too"); + // Exactly at a threshold is a drag: the reference's compare is `< 8.0`, not `<=`. + assert!(!press(2.25, 0.0).is_click(0.5), "the yaw gate is exclusive"); + assert!( + !press(0.0, 2.0).is_click(0.5), + "the pitch gate is exclusive" + ); + } + + /// The 800 ms ceiling (`0x514aeb lea eax,[edx-0x320]`) is absolute — a long hold is never a + /// click, however still the hand was. This is the arm that keeps a deliberate camera orbit from + /// re-targeting whatever it started on, and it is the reason the fix could not simply be + /// "always select on release". + #[test] + fn a_long_hold_is_never_a_click_however_still() { + let motionless = press(0.0, 0.0); + assert!(motionless.is_click(0.799), "just inside the ceiling"); + assert!(!motionless.is_click(0.8), "the ceiling is exclusive"); + assert!( + !motionless.is_click(5.0), + "a long motionless hold is a drag" + ); + } + /// The self-avatar's zoom-to-first-person fade reaches its BILLBOARD cards — the night-elf eye /// glow (ledger B71: two additive quads left burning in mid-air after the body was hidden). /// A card is a world ROOT following an anchor inside the model (decision 0153), so the fade's diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs index ae20a921..f9315ee1 100644 --- a/crates/benilla-app/src/player/mover.rs +++ b/crates/benilla-app/src/player/mover.rs @@ -16,8 +16,12 @@ //! - a walkable slope never slows or deflects the walk: the real client's walk is //! two-dimensional (speed·dt of *horizontal* distance), so an opposing walkable plane rides //! instead of clipping ([`walkable_ride_velocity`]) — full 2D speed on every ≤50° surface; -//! - a steep face in the way runs the atomic step-up ([`step_up`]): rise–advance–settle onto -//! a walkable floor, committed whole within the frame or not at all (decision 0209); +//! - a steep face in the way is first *certified* by the atomic step-up ([`step_up`]): +//! rise–advance–settle onto a walkable floor, or nothing (decision 0209). What a certified +//! obstacle then costs is the reference's two-regime law (decision 1123): a rise inside the +//! foot cone ([`FOOT_CONE_HEIGHT`]) is **ridden** up the cone's 61.6° skirt over the frames the +//! gait needs ([`foot_cone_ride`]) — a kerb takes three at a run; only a rise above the cone is +//! the instant pop, committed whole within the frame. Uncertified, nothing rises at all; //! - a steep face never *lifts* the mover: when the slide's clip would convert a push into //! upward motion, the face clips as a vertical wall instead ([`steep_wall_plane`]) — you //! rub along trunks and steep banks, never up them; @@ -34,10 +38,10 @@ use bevy::prelude::*; use crate::collision::player_query_filter; use super::{ - move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, GRAVITY, - GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, - STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, - WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, + move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, + FOOT_CONE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, + JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, + STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, }; /// What the step decided — read by the move-flags / wire logic that follows it in `control`. @@ -141,7 +145,13 @@ pub(super) fn step( // can begin under a root by any path. (wow-re `moveflag-family.md` §1/§5.3, // `step-vs-fall-election.md`.) let anchored = !held && player.modes.rooted; - let on_floor = !held && on_walkable && player.vel_y <= 0.0; + // A body part-way up a foot cone is standing (decision 1123). The probe above looks straight + // down and finds only the steep riser it is riding, so on its own it would call a mid-ride frame + // airborne — gravity would then undo the climb and the body would dwell on the face, the exact + // failure 0209's atomic commit was built to make impossible. The ride is re-earned from the + // certification every frame it continues, so this can hold nothing up that has not just proved + // it can be climbed. + let on_floor = !held && (on_walkable || player.cone_riding) && player.vel_y <= 0.0; // The wedged rest (decision 0211) stands until real ground takes over or the support // vanishes — we walked off the funnel wall into open air, which resumes a normal fresh fall. @@ -165,6 +175,7 @@ pub(super) fn step( if want_jump { player.vel_y = JUMP_SPEED; player.wedged = false; + player.cone_riding = false; jumped = true; } } else { @@ -212,9 +223,23 @@ pub(super) fn step( time.delta(), hover_offset, ); + // The step-up probe (this is the LOCAL mover; a remote's dead-reckon is not a report + // anyone is looking at): a walk frame that went nowhere writes the `stup` deep report — + // the surface profile ahead, the advance ladder, the candidate faces. + super::step_probe::watch( + ms, + capsule, + &filter, + center, + g.center, + player.horiz_vel, + dt, + time.elapsed_secs(), + ); center = g.center; climb = g.climb; snap_probe = g.snap; + player.cone_riding = g.cone_riding; if let Some(e) = g.ground { ground_entity = Some(e); } @@ -229,6 +254,8 @@ pub(super) fn step( // The airborne slide is the OTHER shared resolve ([`airborne_step`]) — the same code a // remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627). center = airborne_step(ms, capsule, &filter, center, velocity, time.delta()); + // Nothing here can be riding a cone: this arm is the arc, the hold and the anchor. + player.cone_riding = false; } // Wedge-rest detection (decisions 0211/0212): airborne, already falling fast, yet the // descent achieved is a sliver of what gravity intended — [`WEDGE_STILL_FRAMES`] in a row @@ -332,8 +359,13 @@ pub(crate) struct GroundedStep { /// `None` means "keep whatever the caller already believed" — a step-up commit and a missed /// snap both leave the support unchanged. pub(crate) ground: Option, - /// The atomic step-up's committed height gain (yd), when the maneuver ran. + /// The height gain (yd) this frame's climb achieved — the atomic step-up's committed rise, or + /// the distance a foot-cone ride carried the body up its skirt. pub(crate) climb: Option, + /// The body is part-way up a **foot-cone ride** and is *supported by the edge it is riding*, + /// not standing on a floor — so the caller must keep treating it as grounded until a walkable + /// floor takes over (decision 1123). Always `false` for a frame that did not ride. + pub(crate) cone_riding: bool, /// The election snap's `(probe reach, what it found)` — trace fodder, `None` when the step-up /// took the frame instead. The inner pair is `(hit distance, hit normal.y)`. pub(crate) snap: Option<(f32, Option<(f32, f32)>)>, @@ -377,21 +409,81 @@ pub(crate) fn grounded_step( // every stuck/bounce report of the step-up era was that dwelling). Grazing a face nets // back onto the same floor (reads as sliding); a square push onto a low step lands on its // top; anything taller than [`STEP_UP_HEIGHT`] never commits. - let stepped = if speed > 1.0e-6 { - step_up(&cast, center, horiz_vel / speed, speed * dt.as_secs_f32()) + let attempt = if speed > 1.0e-6 { + let travel = speed * dt.as_secs_f32(); + // The look-ahead is this frame's travel — "is there a steep face in my way *now*" is a + // question about this frame. The **advance** is not: how far forward the maneuver must + // reach to see the tread it would stand on is a property of the body, so it is at least + // [`STEP_UP_ADVANCE`] whatever the frame rate or the gait (decision 1121). Travel still + // wins when it is longer, so a very low frame rate never steps you less far than you asked + // to walk. + step_up( + &cast, + center, + horiz_vel / speed, + travel, + travel.max(STEP_UP_ADVANCE), + ) } else { - None + StepAttempt { + contact: None, + verdict: StepVerdict::NoFace, + } }; - if let Some(landed) = stepped { - // The committed maneuver IS this frame's motion — already settled on a walkable floor, - // so the slide and the snap below are skipped. - return GroundedStep { - center: landed, - ground: None, - climb: Some(landed.y - center.y), - snap: None, - }; + // The certify trace (`WOW_MOVE_TRACE`, tag `step`): one line per attempt with every probe + // number and the world-space contact, so a feel report pins to the exact placement and probe — + // the instrument that broke the fence/tree cases, which reasoning alone could not. (The + // *blocked-frame* deep report — the advance ladder and the surface profile — is the `stup` tag + // in [`super::step_probe`], which the local mover fires when a walk frame goes nowhere.) + if let Some((point, n)) = attempt.contact { + if crate::dbg_trace::enabled_for("step") { + let feet_y = center.y - CAPSULE_HEIGHT * 0.5; + crate::dbg_trace::line( + "step", + &format!( + "hit ({:8.2},{:7.2},{:8.2}) h={:+.2} n=({:+.2},{:+.2},{:+.2}) {}", + point.x, + point.y, + point.z, + point.y - feet_y, + n.x, + n.y, + n.z, + attempt.verdict + ), + ); + } } + // **Which regime** (decision 1123, wow-re `climb-vs-slide.md` §2/§4/§6). The certification above + // settles *whether* the obstacle can be cleared; its committed rise settles *how*. The real + // client's solid is a cone below [`FOOT_CONE_HEIGHT`] and a vertical box above it, so a low edge + // never meets a wall to be lifted over — it meets the slanted skirt, and the ordinary slide + // carries the body up it. Only an edge that clears the cone meets the box square, and that + // square meeting is the instant step-up. One law, two outcomes, selected by the rise: + // - `dy ≤ FOOT_CONE_HEIGHT` ⇒ **ride** the skirt at atan 1.8494 ≈ 61.6°, over as many frames + // as the gait needs (a kerb takes two at a run) — smooth, and never a teleport; + // - `dy > FOOT_CONE_HEIGHT` ⇒ the atomic pop of 0209, unchanged; + // - no certification at all ⇒ neither: the plain slide, and the body never rises. (This is + // the gate that keeps the ride honest — a tall wall's contact point sits *inside* the cone + // band too, so height alone would ride it. Only the certification distinguishes them.) + let ride_to = match attempt.verdict { + StepVerdict::Commit { landed, dy, .. } if dy <= FOOT_CONE_HEIGHT => Some(landed.y), + _ => None, + }; + if ride_to.is_none() { + if let Some(landed) = attempt.verdict.landed() { + // The committed maneuver IS this frame's motion — already settled on a walkable floor, + // so the slide and the snap below are skipped. + return GroundedStep { + center: landed, + ground: None, + climb: Some(landed.y - center.y), + snap: None, + cone_riding: false, + }; + } + } + let mut rode = false; let out = crate::collision::one_sided::move_and_slide( ms, capsule, @@ -405,6 +497,18 @@ pub(crate) fn grounded_step( *hit.velocity = ride; return MoveAndSlideHitResponse::Accept; } + // The foot cone's skirt, on a certified low edge only. The ceiling is the + // certification's own landing: once the body is that high the obstacle is cleared, so + // the skirt has nothing left to ride and any further contact is an ordinary wall. Any + // overshoot inside a sub-step is taken back out by the election snap below, which only + // ever descends onto a walkable floor. + if ride_to.is_some_and(|ceiling| hit.position.y < ceiling) { + if let Some(up) = foot_cone_ride(**hit.normal, *hit.velocity) { + *hit.velocity = up; + rode = true; + return MoveAndSlideHitResponse::Accept; + } + } if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) { if let Ok(wall) = Dir::new(wall) { *hit.normal = wall; @@ -450,8 +554,14 @@ pub(crate) fn grounded_step( } GroundedStep { center: slid, + // A ride is a climb too — the trace reads the same whichever regime moved the body. + climb: rode.then_some(slid.y - center.y), + // Mid-ride the body is held up by the edge under its skirt, and the frame-start ground + // probe can only see the steep riser there. Say so, so the caller keeps it standing + // instead of letting gravity undo the climb. A snap onto a walkable floor ends the ride: + // the tread is under the feet and ordinary grounding takes over from here. + cone_riding: rode && ground.is_none(), ground, - climb: None, snap, } } @@ -519,6 +629,40 @@ fn walkable_ride_velocity(n: Vec3, v: Vec3) -> Option { Some(Vec3::new(v.x, -(v.x * n.x + v.z * n.z) / n.y, v.z)) } +/// **The foot cone's ride** — the smooth half of the reference's climb law (decision 1123). +/// +/// The real client's movement solid is a **cone below the waist**: the k-DOP build at `0x631440` +/// emits four bevels running from a point at the foot out to the full radius at +/// `foot + radius·1.8493990`, and only above that height is it a vertical box (wow-re +/// `climb-vs-slide.md` §2 — the `n.z < 0` sign on those planes is the tell that the cone narrows +/// *downward*, so it is a foot cone and not a top-rim chamfer). A low edge therefore never presents +/// the mover a wall to be lifted over: it presents the slanted skirt, and the resolver's own slide +/// runs the body up it. +/// +/// The gain is the note's `T` at §4 — `1.8494 · cosθ · len`, where `θ` is how squarely the approach +/// meets the face — and that is exactly this projection: the closing horizontal speed (the dot +/// product supplies `cosθ`) times [`STEP_SLOPE_RATIO`], the cone's own surface slope. Horizontal +/// speed is untouched, as it is on every walkable ride ([`walkable_ride_velocity`]); the grounded +/// mover owns no vertical of its own, so this *sets* the vertical rather than adding to it. +/// +/// Only steep, non-overhanging, opposing faces ride — a walkable face already had its ride, and an +/// overhang is a ceiling. **The caller owns the real gate:** this says only "here is what the skirt +/// would do", never "the body may climb". Whether the obstacle can be cleared at all is the +/// certification's call in [`grounded_step`], because a tall wall's contact point sits inside the +/// cone band too and height alone cannot tell the two apart. +fn foot_cone_ride(n: Vec3, v: Vec3) -> Option { + if !(0.0..GROUND_COS).contains(&n.y) { + return None; + } + // Steepness bounds the horizontal part below by sin 50°, so the normalize is safe. + let h = Vec3::new(n.x, 0.0, n.z).normalize(); + let into = -(v.x * h.x + v.z * h.z); + if into <= 0.0 { + return None; + } + Some(Vec3::new(v.x, into * STEP_SLOPE_RATIO, v.z)) +} + /// The steep-face wall rule: a steep (non-walkable, non-overhanging) face must never *lift* /// the mover. Collide-and-slide clips velocity onto each contact plane, and on a tilted plane /// that clip manufactures upward motion out of a horizontal push (`v'.y − v.y = −(v·n)·n.y`, @@ -546,15 +690,99 @@ fn steep_wall_plane(n: Vec3, v: Vec3) -> Option { Some(Vec3::new(n.x, 0.0, n.z).normalize()) } +/// What one atomic step-up attempt decided — the structured form of the `step` trace line. +/// +/// Structured rather than logged-and-forgotten because the diagnostic probe +/// ([`super::step_probe`]) re-runs the *same* maneuver at a ladder of forward advances and reads +/// the reason back off every rung. "Why did this step fail, and what would have made it succeed" +/// is then one table in the trace, not a text parse of six different format strings. +#[derive(Clone, Copy, Debug)] +pub(crate) enum StepVerdict { + /// Nothing steep and opposing within the look-ahead — not a step-up frame at all. + NoFace, + /// The rise found no headroom above the capsule. + NoHeadroom, + /// The settle probe found no floor at all under the advanced point. + NoFloor { up: f32, fwd: f32 }, + /// The settle found a floor, but one too steep to stand on (`ny` under [`GROUND_COS`]). + SteepFloor { + up: f32, + fwd: f32, + dist: f32, + ny: f32, + }, + /// The maneuver gained no height — a graze, a too-tall wall, or a pinch's gap floor. The plain + /// slide owns the frame. + NetZero { + up: f32, + fwd: f32, + dist: f32, + ny: f32, + dy: f32, + }, + /// Committed: this landing **is** the frame's motion. + Commit { + landed: Vec3, + up: f32, + fwd: f32, + dy: f32, + }, +} + +impl StepVerdict { + /// The committed capsule centre, if this attempt took the frame. + pub(crate) fn landed(self) -> Option { + match self { + StepVerdict::Commit { landed, .. } => Some(landed), + _ => None, + } + } +} + +impl std::fmt::Display for StepVerdict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + StepVerdict::NoFace => write!(f, "no opposing face"), + StepVerdict::NoHeadroom => write!(f, "up=0.00 NO-HEADROOM -> slide"), + StepVerdict::NoFloor { up, fwd } => { + write!(f, "up={up:.2} fwd={fwd:.2} down=miss NO-FLOOR -> slide") + } + StepVerdict::SteepFloor { up, fwd, dist, ny } => write!( + f, + "up={up:.2} fwd={fwd:.2} down=(d={dist:.2} ny={ny:+.2}) STEEP-FLOOR -> slide" + ), + StepVerdict::NetZero { + up, + fwd, + dist, + ny, + dy, + } => write!( + f, + "up={up:.2} fwd={fwd:.2} down=(d={dist:.2} ny={ny:+.2}) dy={dy:+.3} NET-ZERO -> slide" + ), + StepVerdict::Commit { up, fwd, dy, .. } => { + write!(f, "up={up:.2} fwd={fwd:.2} dy={dy:+.3} -> COMMIT") + } + } + } +} + +/// One step-up attempt: the opposing face that triggered it (world contact point + its authored +/// normal), and what the maneuver decided about it. +pub(crate) struct StepAttempt { + /// `None` when no steep opposing face was within the look-ahead — there was nothing to step. + pub(crate) contact: Option<(Vec3, Vec3)>, + pub(crate) verdict: StepVerdict, +} + /// The atomic step-up (decision 0209) — the standard kinematic-controller maneuver, *not* the -/// reference resolver's (that direction is closed, 0207): a steep opposing face within this -/// frame's travel triggers **rise → advance → settle**, committed whole inside this one frame, -/// or nothing. +/// reference resolver's (that direction is closed, 0207): a steep opposing face within `look` +/// triggers **rise → advance → settle**, committed whole inside this one frame, or nothing. /// /// - **Rise** by the free headroom, at most [`STEP_UP_HEIGHT`] — the deliberately low ceiling /// that scopes this to stairs/doorsteps/low rocks and keeps fences and walls slide-only. -/// - **Advance** this frame's own travel along the *input* direction at the raised height — -/// never a probe-length lunge. +/// - **Advance** by `advance` along the *input* direction at the raised height. /// - **Settle** back down by the walk election's own reach; commit **only onto a walkable /// floor that is actually higher**. /// @@ -564,91 +792,334 @@ fn steep_wall_plane(n: Vec3, v: Vec3) -> Option { /// the origin floor ⇒ slide; a pinch between two tree trunks offers only steep landings ⇒ /// **no commit, ever** — the wedge/bounce class of 0191–0195 is impossible by construction, /// because there is no intermediate mid-climb state to be caught in. -fn step_up( +/// +/// **`look` and `advance` are separate parameters only so the maneuver is measurable.** The live +/// mover passes this frame's own travel for both (0209's design — never a probe-length lunge); +/// the diagnostic probe ([`super::step_probe`]) sweeps `advance` to find the offset at which the +/// settle probe would have cleared the obstacle's lip, which is the number a "it won't step up +/// this curb" report is actually about. +pub(crate) fn step_up( cast: &impl Fn(Vec3, Vec3) -> Option, center: Vec3, dir_h: Vec3, - travel: f32, -) -> Option { - // A steep, non-overhanging face opposing the motion, within this frame's travel (+skin). + look: f32, + advance: f32, +) -> StepAttempt { + let none = |verdict| StepAttempt { + contact: None, + verdict, + }; + // A steep, non-overhanging face opposing the motion, within `look` (+skin). // No incidence gate — the verified ref has none; grazing nets zero through the settle. - let ahead = cast(center, dir_h * travel)?; + let Some(ahead) = cast(center, dir_h * look) else { + return none(StepVerdict::NoFace); + }; let n = ahead.normal1; if n.y >= GROUND_COS || n.y < 0.0 || n.dot(dir_h) >= 0.0 { - return None; + return none(StepVerdict::NoFace); } - // The certify trace (`WOW_MOVE_TRACE`): one `step` line per attempt with every probe number - // and the world-space contact, so a feel report pins to the exact placement and probe — - // the instrument that broke the fence/tree cases, which reasoning alone could not. - let feet_y = center.y - CAPSULE_HEIGHT * 0.5; - let hit = ahead.point1; - let log = |verdict: &str| { - if crate::dbg_trace::enabled() { - crate::dbg_trace::line( - "step", - &format!( - "hit ({:8.2},{:7.2},{:8.2}) h={:+.2} n=({:+.2},{:+.2},{:+.2}) {}", - hit.x, - hit.y, - hit.z, - hit.y - feet_y, - n.x, - n.y, - n.z, - verdict - ), - ); - } + let at = |verdict| StepAttempt { + contact: Some((ahead.point1, n)), + verdict, }; // Rise: the free headroom, at most H. - let up_t = cast(center, Vec3::Y * STEP_UP_HEIGHT).map_or(STEP_UP_HEIGHT, |h| h.distance); - if up_t < 1e-3 { - log("up=0.00 NO-HEADROOM -> slide"); - return None; + let up = cast(center, Vec3::Y * STEP_UP_HEIGHT).map_or(STEP_UP_HEIGHT, |h| h.distance); + if up < 1e-3 { + return at(StepVerdict::NoHeadroom); } - // Advance: this frame's travel along the input dir, swept at the raised height. - let raised = center + Vec3::Y * up_t; - let fwd_t = cast(raised, dir_h * travel).map_or(travel, |h| h.distance); - let over = raised + dir_h * fwd_t; + // Advance: along the input dir, swept at the raised height. + let raised = center + Vec3::Y * up; + let fwd = cast(raised, dir_h * advance).map_or(advance, |h| h.distance); + let over = raised + dir_h * fwd; // Settle: the walk election's reach below the advanced point — the rise undone, plus the // travel-scaled step-down allowance (decisions 0182/0190) — onto a WALKABLE floor only. - let reach = up_t + travel * STEP_SLOPE_RATIO + STEP_SNAP_SLACK; + let reach = up + advance * STEP_SLOPE_RATIO + STEP_SNAP_SLACK; let Some(down) = cast(over, Vec3::NEG_Y * reach) else { - log(&format!( - "up={up_t:.2} fwd={fwd_t:.2} down=miss NO-FLOOR -> slide" - )); - return None; + return at(StepVerdict::NoFloor { up, fwd }); }; - if down.normal1.y < GROUND_COS { - log(&format!( - "up={up_t:.2} fwd={fwd_t:.2} down=(d={:.2} ny={:+.2}) STEEP-FLOOR -> slide", - down.distance, down.normal1.y - )); - return None; + let (dist, ny) = (down.distance, down.normal1.y); + if ny < GROUND_COS { + return at(StepVerdict::SteepFloor { up, fwd, dist, ny }); } - let landed = over + Vec3::NEG_Y * down.distance; + let landed = over + Vec3::NEG_Y * dist; let dy = landed.y - center.y; // Commit only a landing that actually gained a floor. A net-zero maneuver (grazing a face, // pushing a too-tall wall, the tree pinch's gap grass) belongs to the plain slide — its // deflection is what "sliding along the fence" is; committing here would dead-stop it. if dy <= 0.05 { - log(&format!( - "up={up_t:.2} fwd={fwd_t:.2} down=(d={:.2} ny={:+.2}) dy={dy:+.3} NET-ZERO -> slide", - down.distance, down.normal1.y - )); - return None; + return at(StepVerdict::NetZero { + up, + fwd, + dist, + ny, + dy, + }); } - log(&format!( - "up={up_t:.2} fwd={fwd_t:.2} down=(d={:.2} ny={:+.2}) dy={dy:+.3} -> COMMIT", - down.distance, down.normal1.y - )); - Some(landed) + at(StepVerdict::Commit { + landed, + up, + fwd, + dy, + }) } #[cfg(test)] mod tests { use super::*; + use crate::player::CAPSULE_RADIUS; + use bevy::ecs::system::RunSystemOnce; + + /// A headless physics world holding **the kerb the director's capture measured** — Stormwind + /// Trade District, decision 1121: a 0.28 yd sidewalk whose riser is a ~61° bevel, not a + /// vertical face. Profiled from the `stup` down-ray scan at the real spot (street out to + /// +0.20, the bevel's face normal `ny=+0.49`, flat tread `ny=+0.99` from +0.50 on), so the + /// fixture is the geometry, not an idea of it. + /// + /// The profile is a `(x, y)` polyline extruded across `z`, wound so every face's **authored** + /// normal points up and back at the approaching body — the one-sided law (0970) is live in + /// these casts, so a mis-wound fixture would silently be a hole to fall through. + fn world_with_kerb() -> App { + const PROFILE: [(f32, f32); 4] = [(-2.0, 0.0), (0.29, 0.0), (0.446, 0.28), (3.0, 0.28)]; + const W: f32 = 3.0; + let mut app = App::new(); + // avian's collider backend reads `Assets` and `SceneSpawner` even in a meshless + // world, so the headless asset/scene plugins ride along. + app.add_plugins(( + MinimalPlugins, + bevy::transform::TransformPlugin, + bevy::asset::AssetPlugin::default(), + bevy::scene::ScenePlugin, + PhysicsPlugins::new(bevy::app::PostUpdate), + )); + app.init_asset::(); + let (mut verts, mut tris) = (Vec::new(), Vec::new()); + for w in PROFILE.windows(2) { + let (&(x0, y0), &(x1, y1)) = (&w[0], &w[1]); + let b = verts.len() as u32; + verts.extend([ + Vec3::new(x0, y0, -W), + Vec3::new(x1, y1, -W), + Vec3::new(x1, y1, W), + Vec3::new(x0, y0, W), + ]); + // (a, c, b) / (a, d, c): normal = (-dy, dx, 0) — up for the flats, up-and-back for + // the riser. The reverse winding is a backface and blocks nothing at all. + tris.extend([[b, b + 2, b + 1], [b, b + 3, b + 2]]); + } + app.world_mut().spawn(( + RigidBody::Static, + Collider::trimesh(verts, tris), + Transform::default(), + )); + app.update(); // one frame builds Position/Rotation and the spatial-query trees + app + } + + fn player_capsule() -> Collider { + Collider::capsule(CAPSULE_RADIUS, CAPSULE_HEIGHT - 2.0 * CAPSULE_RADIUS) + } + + /// Walk the capsule into the kerb exactly as the mover does, then run one step-up attempt at + /// `advance`. Returns the verdict from where the slide actually left the body — not from a + /// hand-placed pose, which is how a fixture ends up testing a spot the mover never reaches. + fn step_at(advance: f32) -> StepVerdict { + world_with_kerb() + .world_mut() + .run_system_once(move |ms: MoveAndSlide| { + let capsule = player_capsule(); + let filter = SpatialQueryFilter::default(); + let cast = |from: Vec3, disp: Vec3| { + crate::collision::one_sided::cast_move( + &ms, &capsule, from, disp, SKIN_WIDTH, &filter, + ) + }; + // Approach from 1 yd back along +X at street level and stop where the kerb stops us. + let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5, 0.0); + let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance); + let center = start + Vec3::X * run; + step_up(&cast, center, Vec3::X, TRAVEL_60FPS, advance).verdict + }) + .unwrap() + } + + /// One frame's travel at a run (7.0 yd/s) and 60 fps — 0209's advance, and the number the + /// capture caught failing. + const TRAVEL_60FPS: f32 = 7.0 / 60.0; + + /// Walk the capsule into the kerb and run `frames` consecutive **whole** grounded steps from + /// wherever the last one left it — the mover's own loop, so what these assert is the behaviour + /// on screen and not a single probe in isolation. + fn walk_kerb(start_y: f32, frames: usize) -> Vec<(f32, Option, bool)> { + world_with_kerb() + .world_mut() + .run_system_once(move |ms: MoveAndSlide| { + let capsule = player_capsule(); + let filter = SpatialQueryFilter::default(); + let cast = |from: Vec3, disp: Vec3| { + crate::collision::one_sided::cast_move( + &ms, &capsule, from, disp, SKIN_WIDTH, &filter, + ) + }; + let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5 + start_y, 0.0); + let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance); + let mut center = start + Vec3::X * run; + let dt = std::time::Duration::from_secs_f32(TRAVEL_60FPS / 7.0); + (0..frames) + .map(|_| { + let g = + grounded_step(&ms, &capsule, &filter, center, Vec3::X * 7.0, dt, 0.0); + center = g.center; + // Feet height relative to the street, the climb, and the ride latch. + (center.y - CAPSULE_HEIGHT * 0.5, g.climb, g.cone_riding) + }) + .collect::>() + }) + .unwrap() + } + + #[test] + fn the_kerb_is_ridden_up_its_skirt_never_popped() { + // Decision 1123, the whole point: a 0.28 yd kerb is inside the foot cone + // ([`FOOT_CONE_HEIGHT`] ≈ 0.62), so the certified obstacle is *ridden* — a smooth diagonal + // over the frames the gait needs — instead of the body being teleported onto the tread in + // one frame. Both halves are asserted, because either alone is satisfiable by a bug: it + // must arrive on the tread, AND no single frame may deliver the whole rise. + let frames = walk_kerb(0.0, 4); + let arrived = frames + .iter() + .position(|&(y, _, _)| (y - 0.28).abs() < 0.03) + .expect("the ride should put the feet on the 0.28 yd tread"); + assert!( + arrived > 0, + "arriving on the very first frame is the pop, not a ride: {frames:?}" + ); + assert!( + frames[0].2, + "the first frame is mid-ride and must report itself grounded: {frames:?}" + ); + assert!( + frames[0].0 > 0.0 && frames[0].0 < 0.28, + "the first frame should be part-way up the skirt, got {:+.3}", + frames[0].0 + ); + } + + #[test] + fn a_wall_is_never_ridden() { + // The certification is the gate, not the height (a tall wall's contact point sits inside + // the cone band too). Read the same geometry from a full kerb below — a 2.3 yd wall — and + // nothing may rise: no ride, no climb, no lift, just the slide. This is the check that + // stops the ride becoming a ladder up every cliff in the world. + for (y, climb, riding) in walk_kerb(-2.0, 4) { + assert!(!riding, "a wall must never start a cone ride"); + assert!(climb.is_none(), "a wall must never register a climb"); + assert!( + y < -2.0 + 0.05, + "the body must not rise against a wall, feet at {y:+.3}" + ); + } + } + + #[test] + fn the_cone_ride_gains_the_reference_slope() { + // wow-re `climb-vs-slide.md` §4: `T = 1.8494 · cosθ · len`. Head-on, the gain is the cone's + // own surface slope times the speed… + let head_on = foot_cone_ride(Vec3::new(-1.0, 0.0, 0.0), Vec3::X * 7.0).unwrap(); + assert!( + (head_on.y - 7.0 * STEP_SLOPE_RATIO).abs() < 1e-4, + "{head_on:?}" + ); + assert_eq!( + head_on.x, 7.0, + "horizontal speed is never touched by a ride" + ); + // …and meeting the same face at 60° gains exactly cos60° of it, which is the `cosθ` term + // falling out of the closing-speed projection rather than being applied by hand. + let oblique = foot_cone_ride( + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(7.0 * 0.5, 0.0, 7.0 * 0.866_025_4), + ) + .unwrap(); + assert!( + (oblique.y - 7.0 * 0.5 * STEP_SLOPE_RATIO).abs() < 1e-3, + "{oblique:?}" + ); + } + + #[test] + fn the_cone_ride_declines_what_is_not_its_business() { + let v = Vec3::X * 7.0; + // A walkable face already had its ride ([`walkable_ride_velocity`])… + assert!(foot_cone_ride(Vec3::new(-0.5, 0.866, 0.0).normalize(), v).is_none()); + // …an overhang is a ceiling, not a skirt… + assert!(foot_cone_ride(Vec3::new(-0.5, -0.866, 0.0).normalize(), v).is_none()); + // …and a face we are moving away from is not in the way at all. + assert!(foot_cone_ride(Vec3::new(1.0, 0.0, 0.0), v).is_none()); + } + + #[test] + fn the_kerb_is_out_of_reach_of_one_frames_travel() { + // The defect the capture pinned (decision 1121): the settle probe is still over the bevel + // at 0.117 yd, so it lands on a 61° face and the walkable gate — correctly — refuses it. + // The step-up is not wrong about the face; it never looked far enough to see the tread. + let v = step_at(TRAVEL_60FPS); + assert!( + matches!(v, StepVerdict::SteepFloor { .. }), + "one frame's travel should still be over the bevel, got {v}" + ); + } + + #[test] + fn a_body_scaled_advance_climbs_the_kerb() { + // …and a body radius ahead, the same probe is over the tread and commits onto it. `dy` is + // the kerb's real height, so this pins that we land ON the sidewalk, not part-way up its + // bevel. + let v = step_at(STEP_UP_ADVANCE); + let StepVerdict::Commit { dy, .. } = v else { + panic!("a body-radius advance should reach the tread, got {v}"); + }; + assert!( + (dy - 0.28).abs() < 0.03, + "should land on the 0.28 yd tread, gained {dy:+.3}" + ); + } + + #[test] + fn the_advance_never_climbs_past_the_rise_ceiling() { + // The reach grew; what may be climbed did not (decision 1121). A wall taller than + // [`STEP_UP_HEIGHT`] clips the elevated sweep, so the settle falls back to the origin + // floor and the plain slide keeps the frame — the fence/trunk behaviour 0209 was built + // for, asserted at the advance that made the kerb work. + let v = world_with_kerb() + .world_mut() + .run_system_once(move |ms: MoveAndSlide| { + let capsule = player_capsule(); + let filter = SpatialQueryFilter::default(); + let cast = |from: Vec3, disp: Vec3| { + crate::collision::one_sided::cast_move( + &ms, &capsule, from, disp, SKIN_WIDTH, &filter, + ) + }; + // Stand the body a full kerb below the tread — the same geometry read as a 2.3 yd + // wall by dropping the approach to y = −2.0, where the tread is far overhead. + let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5 - 2.0, 0.0); + let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance); + step_up( + &cast, + start + Vec3::X * run, + Vec3::X, + TRAVEL_60FPS, + STEP_UP_ADVANCE, + ) + .verdict + }) + .unwrap(); + assert!( + v.landed().is_none(), + "a face above the rise ceiling must never commit, got {v}" + ); + } /// Outward normal of a face rising toward +x, tilted `deg` from horizontal. fn face(deg: f32) -> Vec3 { diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index e5726f29..7cfbdaae 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -118,6 +118,47 @@ pub(super) const STEP_SNAP_SLACK: f32 = 0.027_777_8; /// deliberately NOT the reference's ~2 yd body-height budget, so fences (collision tops /// 1.8–2.3 yd) always slide. One number to nudge if a real spot feels too restrictive. pub(super) const STEP_UP_HEIGHT: f32 = 0.7; +/// The step-up **certify advance** (yd): how far ahead the maneuver looks for the tread it would +/// stand on. A property of the BODY, never of the frame (decision 1121). +/// +/// 0209 advanced by this frame's own travel, which made every kerb in the game a frame-rate +/// lottery: at 60 fps a run is 0.117 yd of travel, at 144 fps it is 0.049, and walking is 0.041 — +/// while the capsule's own radius is [`CAPSULE_RADIUS`]. A settle probe that far forward is still +/// over the riser, so it lands on the riser's face and the walkable gate rejects it. Measured at +/// Stormwind's Trade District kerbs (decision 1121): the live 0.117 rung fails, 0.20 and beyond +/// commit onto the tread, at every one of the seven captured contacts. Sidewalks 0.28 yd tall +/// refused to be stepped onto because the probe never reached them. +/// +/// A body radius is the principled value: it is exactly the distance at which the capsule's +/// footprint has cleared the lip it is standing against, and it is the same number whatever the +/// frame rate or the gait. The frame's travel still wins when it is *longer* (a very low frame +/// rate must not step you less far than you asked to walk), so this is a floor, not a fixed reach. +/// +/// It does not widen what may be climbed: the rise ceiling is still [`STEP_UP_HEIGHT`], the settle +/// must still find a *walkable* floor higher than the feet, and the elevated forward sweep is still +/// clipped by anything in the way — so a fence, a trunk and a two-trunk pinch all still block at +/// the same body height they did before, with a clipped advance and a net-zero settle. +pub(super) const STEP_UP_ADVANCE: f32 = CAPSULE_RADIUS; +/// The **foot cone's height** (yd): how far above the feet the reference's movement solid is still +/// narrower than its full radius — and therefore the band within which a blocking edge is *ridden +/// up* rather than stepped onto (decision 1123). +/// +/// The real client's movement solid is not a capsule. Its lower half is a **cone**: the k-DOP build +/// at `0x631440` emits 9 planes — 4 vertical box sides at `center.xy ± radius`, and 4 bevels running +/// from a point at the foot out to `(pos.xy ± radius, pos.z + r')` with `r' = radius · 1.8493990` +/// (`[0x80c740]` — our [`STEP_SLOPE_RATIO`]), above which it is a plain vertical box. There is no +/// −Z plane: the solid is open at the bottom (wow-re `climb-vs-slide.md` §2). +/// +/// So a *low* edge never meets a wall — it meets the slanted skirt, and the resolver's ordinary +/// slide carries the body up it at the cone's own surface slope, atan 1.8494 ≈ 61.6°, gaining +/// `1.8494 · cosθ · len` per step (the note's `T` at §4). Only an edge **above** this height meets +/// the vertical box square, and that is the case the instant step-up ([`STEP_UP_HEIGHT`]) exists +/// for. One solid, two behaviours, selected by the edge's height above the feet. +/// +/// Not a new tunable: it is our own two constants multiplied, and it reproduces the reference's +/// ≈0.62 yd exactly, because [`CAPSULE_RADIUS`] is already the reference's own mover radius of 1/3 +/// (`climb-vs-slide.md` §2 names it). +pub(super) const FOOT_CONE_HEIGHT: f32 = CAPSULE_RADIUS * STEP_SLOPE_RATIO; /// The landing probe (yd): while airborne, walk mode resumes only this close to the floor, so /// the arc ends where the slide actually contacts (skin scale) instead of [`GROUND_PROBE`] /// early — which cut the last ~0.2 yd of every fall into a same-frame snap, the visible pop at @@ -467,6 +508,13 @@ pub(crate) struct Player { pub(super) wedged: bool, /// Consecutive stalled airborne frames (see [`WEDGE_STALL_RATIO`]). pub(super) wedge_still: u8, + /// Part-way up a **foot-cone ride** — the body is resting on the slanted skirt of a low edge it + /// has already certified it can clear, climbing it over the frames the gait needs (decision + /// 1123). Counts as standing: the frame-start ground probe sees only the steep riser under the + /// feet, so without this the mid-ride frame reads as airborne and gravity undoes the climb — + /// the mid-face dwelling 0209 was built to make impossible. Re-earned from the certification + /// every frame (no certification, no ride), and cleared the moment a walkable floor takes over. + pub(super) cone_riding: bool, /// The take-off vertical speed (yd/s, WoW +Z up) snapshotted when the airborne phase began — the /// client's `StartFalling` argument (`+0xa0`, constant per arc) and the `zspeed` we send in the /// jump tail: `JUMP_SPEED` for a jump, **exactly 0** for a step-off (the walk election calls diff --git a/crates/benilla-app/src/player/step_probe.rs b/crates/benilla-app/src/player/step_probe.rs new file mode 100644 index 00000000..ce4ca393 --- /dev/null +++ b/crates/benilla-app/src/player/step_probe.rs @@ -0,0 +1,251 @@ +//! **The step-up probe** — the instrument that answers "why won't it climb *this* kerb?". +//! +//! The symptom is always the same from outside the window: you walk into something small, the +//! avatar keeps running on the spot, and nothing in the ordinary trace says why — the atomic +//! step-up ([`super::mover::step_up`]) declined, and one of six different reasons is the truth. +//! Reasoning cannot pick between them (0209's whole history is sessions that tried), and neither +//! can a screenshot, so this measures instead: +//! +//! 1. **The blocked frame is detected, not hunted.** A grounded walk frame whose achieved +//! horizontal displacement is a fraction of what the input asked for *is* the report. No +//! keybind to remember, no guessing which of the director's frames were the bad ones. +//! 2. **The surface profile ahead is measured** with one-sided down rays at a ladder of forward +//! offsets: how tall the thing actually is, whether it has a walkable top at all, and — the +//! reason the rays are one-sided like the mover's own probes — whether that top is even +//! *visible* to a downward sweep under the 0970 facing law. +//! 3. **The maneuver is re-run at a ladder of advances.** The live step-up advances by this +//! frame's own travel (0209), which is 12 cm at 60 fps and 3 cm at 240 — while the capsule +//! radius is [`super::CAPSULE_RADIUS`]. If a rung further out commits where the live one did +//! not, the advance is the defect; if *every* rung says `NO-FLOOR`, the geometry is; if every +//! rung says `STEEP-FLOOR`, the walkable gate is. One line, one verdict. +//! 4. **The candidate faces are dumped** ([`crate::collision::one_sided::faces_near`]), with the +//! facing gate's own answer per face, when the trace asks for them. +//! +//! Output goes two places: the `stup` tag of `WOW_MOVE_TRACE` (the record we read afterwards) and +//! [`latest`], a small ring the debug panel renders live — so the director can *see* the probe +//! fire at the spot they meant, which is what makes a capture worth reading (method §6). +//! +//! Cost: nothing at all on a frame that moves. On a blocked one it is rate-limited to +//! [`REPORT_HZ`] reports a second, each a handful of shape casts and rays — so leaning on a wall +//! forever cannot turn into a frame-rate story of its own. + +use avian3d::character_controller::move_and_slide::MoveAndSlide; +use avian3d::prelude::*; +use bevy::prelude::*; +use std::sync::Mutex; + +use super::mover::{step_up, StepVerdict}; +use super::{CAPSULE_HEIGHT, CAPSULE_RADIUS, SKIN_WIDTH, STEP_UP_HEIGHT}; + +/// A walk frame counts as blocked when it achieved less than this share of the horizontal distance +/// the input asked for. A square push into a wall achieves ~0; a legitimate slide along one at 45° +/// still achieves ~70%, and a walkable-slope ride achieves 100% by construction (decision 0220) — +/// so the band below this is "went nowhere", not "was deflected". +const BLOCKED_SHARE: f32 = 0.35; + +/// Reports per second while blocked. Enough to catch a bump-and-retry, cheap enough to lean on. +const REPORT_HZ: f32 = 5.0; + +/// Forward offsets the surface profile is sampled at (yd). The last two are past anything the +/// maneuver could reach — they say what the geometry *is*, which is how a "there is no top surface +/// here" reads differently from "the top is 0.9 yd away". +const PROFILE: [f32; 8] = [0.0, 0.1, 0.2, 0.35, 0.5, 0.7, 1.0, 1.4]; + +/// Advances the maneuver is re-run at (yd). The live rung — this frame's own travel — is prepended +/// at report time, so the table always opens with what actually happened. +const LADDER: [f32; 6] = [0.1, 0.2, CAPSULE_RADIUS, 0.5, 0.8, 1.2]; + +/// How many candidate faces the geometry dump prints, nearest first. +const FACE_DUMP: usize = 24; + +/// The probe's own state: the rate limiter, and the last report for the panel to render. +struct Probe { + /// App-elapsed seconds of the last report; `f32::MIN` before the first. + last: f32, + /// The most recent report, one string per line — what [`latest`] hands the debug panel. + report: Vec, + /// App-elapsed seconds the report was taken, so the panel can grey out a stale one. + at: f32, +} + +static PROBE: Mutex = Mutex::new(Probe { + last: f32::MIN, + report: Vec::new(), + at: f32::MIN, +}); + +/// The last blocked report (lines) and the app-elapsed time it was taken — the debug panel's read. +pub(crate) fn latest() -> (Vec, f32) { + PROBE + .lock() + .map(|p| (p.report.clone(), p.at)) + .unwrap_or_default() +} + +/// Watch one **local** grounded walk frame, and report it if the body went nowhere. +/// +/// `from`/`to` are the capsule centre either side of [`super::mover::grounded_step`] — the walk +/// resolve alone, before the hover climb and the water-walk clamp, both of which move the body for +/// reasons that have nothing to do with a kerb. +#[allow(clippy::too_many_arguments)] +pub(super) fn watch( + ms: &MoveAndSlide<'_, '_>, + capsule: &Collider, + filter: &SpatialQueryFilter, + from: Vec3, + to: Vec3, + horiz_vel: Vec3, + dt: f32, + now: f32, +) { + let speed = horiz_vel.length(); + let wanted = speed * dt; + // Below a millimetre of intent there is no "blocked" to speak of — standing still, or a frame + // so short that every ratio is noise. + if wanted < 1.0e-3 { + return; + } + let d = to - from; + let got = d.x.hypot(d.z); + if got >= wanted * BLOCKED_SHARE { + return; + } + { + let Ok(mut probe) = PROBE.lock() else { return }; + if now - probe.last < 1.0 / REPORT_HZ { + return; + } + probe.last = now; + } + + let dir_h = horiz_vel / speed; + let feet_y = from.y - CAPSULE_HEIGHT * 0.5; + let cast = |c: Vec3, disp: Vec3| { + crate::collision::one_sided::cast_move(ms, capsule, c, disp, SKIN_WIDTH, filter) + }; + + // The spot is stamped in **WoW** coordinates as well as Bevy's: every other probe, every `.go + // xyz`, and the director's own `/gps` speak that frame, so a blocked report is walkable-back-to + // without anyone converting by hand. (Bevy's are what the rest of this report's geometry is in, + // so both are on the line.) + let feet_wow = benilla_assets::coords::bevy_to_wow(Vec3::new(from.x, feet_y, from.z)); + let mut lines = Vec::with_capacity(5); + lines.push(format!( + "BLOCKED wow ({:9.2},{:9.2},{:7.2}) bevy ({:9.2},{:7.2},{:9.2}) dir({:+.2},{:+.2}) \ + sp {speed:.2} dt {dt:.4} want {wanted:.3} got {got:.3} ({:.0}%) dy {:+.3}", + feet_wow[0], + feet_wow[1], + feet_wow[2], + from.x, + from.y, + from.z, + dir_h.x, + dir_h.z, + 100.0 * got / wanted, + d.y, + )); + + // What we are pressed against — a look-ahead a full radius deep, so the face is found even + // when this frame's own travel is a centimetre and the live maneuver never saw it. + let wall = cast(from, dir_h * CAPSULE_RADIUS); + lines.push(match &wall { + None => format!(" wall none within {CAPSULE_RADIUS:.2} yd ahead"), + Some(h) => format!( + " wall d={:.3} n=({:+.2},{:+.2},{:+.2}) contact ({:8.2},{:7.2},{:8.2}) h={:+.2} {:?}", + h.distance, + h.normal1.x, + h.normal1.y, + h.normal1.z, + h.point1.x, + h.point1.y, + h.point1.z, + h.point1.y - feet_y, + h.entity, + ), + }); + + // The surface profile ahead: one-sided down rays from above the capsule, reported as height + // above the feet. `miss` on a rung with solid geometry under it is the facing law rejecting a + // top face — the reading no shape cast can give, because a sweep that finds nothing and a + // sweep that finds a backface are the same `None`. + let eye = from + Vec3::Y * STEP_UP_HEIGHT; + let ray_len = STEP_UP_HEIGHT + CAPSULE_HEIGHT * 0.5 + 1.0; + let profile: Vec = PROFILE + .iter() + .map(|&o| { + match crate::collision::one_sided::cast_ray( + ms, + eye + dir_h * o, + Dir3::NEG_Y, + ray_len, + filter, + ) { + None => format!("{o:+.2}:miss"), + Some(h) => format!( + "{o:+.2}:{:+.2}/{:+.2}", + eye.y - h.distance - feet_y, + h.normal.y + ), + } + }) + .collect(); + lines.push(format!(" ahead {}", profile.join(" "))); + + // The ladder: the same maneuver, at advances the live one never tries. The live rung first. + let rungs: Vec = std::iter::once(wanted) + .chain(LADDER) + .map(|adv| { + let v = step_up(&cast, from, dir_h, adv.max(wanted), adv).verdict; + // `fwd` — how far the elevated sweep ACTUALLY got — is on every rung, because without + // it a failing far rung is two different stories: "we advanced that far and the floor + // there is steep" and "something at head height stopped us short of it". + let tag = match v { + StepVerdict::NoFace => "no-face".to_string(), + StepVerdict::NoHeadroom => "NO-HEADROOM".to_string(), + StepVerdict::NoFloor { fwd, .. } => format!("NO-FLOOR fwd{fwd:.2}"), + StepVerdict::SteepFloor { fwd, ny, .. } => format!("STEEP fwd{fwd:.2} ny{ny:+.2}"), + StepVerdict::NetZero { fwd, dy, .. } => format!("net-zero fwd{fwd:.2} dy{dy:+.3}"), + StepVerdict::Commit { fwd, dy, .. } => format!("COMMIT fwd{fwd:.2} dy{dy:+.3}"), + }; + format!("{adv:.2}:{tag}") + }) + .collect(); + lines.push(format!(" ladder {}", rungs.join(" | "))); + + // The faces themselves, when the trace asks. Kept off the panel and off an untraced run: it is + // two dozen lines, and it is the answer only when the three readings above disagree. + let mut faces = Vec::new(); + if crate::dbg_trace::enabled_for("stup") { + let at = from + dir_h * CAPSULE_RADIUS; + let half = Vec3::new(1.0, CAPSULE_HEIGHT * 0.5 + STEP_UP_HEIGHT, 1.0); + for f in crate::collision::one_sided::faces_near(ms, at, half, filter, FACE_DUMP) { + let c = f.centroid(); + faces.push(format!( + " face n=({:+.2},{:+.2},{:+.2}) mid ({:8.2},{:7.2},{:8.2}) h={:+.2} \ + down={} fwd={} {:?}", + f.normal.x, + f.normal.y, + f.normal.z, + c.x, + c.y, + c.z, + c.y - feet_y, + if f.blocks(Vec3::NEG_Y) { + "block" + } else { + "PASS " + }, + if f.blocks(dir_h) { "block" } else { "PASS " }, + f.entity, + )); + } + } + + for l in lines.iter().chain(&faces) { + crate::dbg_trace::line("stup", l); + } + if let Ok(mut probe) = PROBE.lock() { + probe.report = lines; + probe.at = now; + } +} diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index efbb9390..03bfaf07 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -43,27 +43,32 @@ pub(super) fn world_right_click_payload( script.clear_cursor_payload(); } -/// On a clean left-click (a [`WorldClick`], never a drag), select whatever unit is [`Hovered`] and -/// inform the server; a click on empty ground / a non-unit clears the target — except a click on -/// NOTHING (sky — no occlusion-ray hit) while a payload is held: the reference's nothing-leg -/// deselect is no-payload-gated (`0x492d30`'s local flag test), while the terrain leg deselects -/// regardless of a surviving spell/action payload (`0x5e03bb` — decisions 0571 + 0574). Skipped -/// while the inspector is armed (left-click is its copy affordance). +/// On a [`WorldClick`], select the unit the **press** was over ([`PressPick`]) and inform the +/// server; a click on empty ground / a non-unit clears the target — except a click on NOTHING (sky +/// — no occlusion-ray hit) while a payload is held: the reference's nothing-leg deselect is +/// no-payload-gated (`0x492d30`'s local flag test), while the terrain leg deselects regardless of a +/// surviving spell/action payload (`0x5e03bb` — decisions 0571 + 0574). Skipped while the inspector +/// is armed (left-click is its copy affordance). +/// +/// **The press pick, not the live hover** (decision 1122). A click may now arrive at the end of a +/// gesture that orbited the camera, and the live hover is empty by then — `update_hover` clears it +/// for the whole look session, as the reference suppresses its own hover during freelook. Reading +/// the live hover here would take the `_ =>` arm below and *clear* the player's target on every +/// drag. The reference picks once, on the down edge, and this consumes that same latch. #[allow(clippy::too_many_arguments)] // one Bevy system's full input set pub(super) fn select_on_click( mut clicks: MessageReader, inspect: Res, - hovered: Res, - cursor: Res, + press: Res, mut selection: ResMut, mut seam: crate::creature_anim::AttackSeam, self_q: Query<(&Guid, Has), With>, payload_held: Res, - occlusion: Res, mut greeting: MessageWriter, ground: Res, click_cfg: Res, ) { + let (hovered, occlusion) = (press.hovered, press.occlusion); // Drain the frame's clicks; act only if there was one and the inspector isn't holding left-click. let clicked = clicks.read().last().is_some(); if !clicked || inspect.enabled { @@ -100,7 +105,7 @@ pub(super) fn select_on_click( guid, engaged, self_guid, - cursor.kind == cursor_mode::CursorKind::Attack, + press.attack, ); } // Clicked nothing targetable → deselect (only sends the clear if we actually had a diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 58076ad3..4834e787 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -94,7 +94,7 @@ pub(crate) struct Selection { /// The unit under the cursor this frame (the net entity + its guid), or `None`. Recomputed every frame /// by [`hover::update_hover`] (plate rects first, then the posed-mesh pick); a click selects it, and /// the mouseover highlight — the model emissive + the plate glow — reads it. -#[derive(Resource, Default)] +#[derive(Resource, Default, Clone, Copy)] pub(crate) struct Hovered { pub(crate) target: Option, pub(crate) guid: Option, @@ -109,7 +109,7 @@ pub(crate) struct Hovered { /// *usable but never selected*. Filled by [`hover::update_hovered_object`] (a GO-only mesh-ray pick); /// read by the cursor classifier (the Interact gear) and the right-click USE action. Both compose it /// with the unit hover by [`go_is_nearest`] — a GO acts only when it is the nearer object. -#[derive(Resource, Default)] +#[derive(Resource, Default, Clone, Copy)] pub(crate) struct HoveredObject { pub(crate) target: Option, pub(crate) guid: Option, @@ -124,7 +124,7 @@ pub(crate) struct HoveredObject { /// and discards the object hit iff the world hit is *strictly* nearer — a unit or GameObject /// behind a wall is not hoverable. Written by [`hover::update_pick_occlusion`] at the head of the /// target chain; both picks post-compare their final hit against it. -#[derive(Resource)] +#[derive(Resource, Clone, Copy)] pub(crate) struct PickOcclusion { pub(crate) distance: f32, /// The hit itself — the world-space point `distance` names along the cursor ray, or `None` @@ -142,6 +142,70 @@ impl Default for PickOcclusion { } } +/// **The pick latched on the button DOWN edge** — what a world click acts on, frozen at the press. +/// +/// The reference picks **exactly once per press** and never again for that gesture: `0x481f00` has +/// one caller image-wide, on the down edge (wow-re `world-click-drag-arbitration.md` §2), and it +/// writes the whole result into the WorldFrame — pick state `+0x350`, guid `+0x358`, hit point +/// `+0x360`, distance `+0x36c`. Nothing re-picks on move or on release, so the release acts on what +/// the **press** was over. That single fact is what lets one gesture orbit the camera *and* select: +/// the mouse may travel anywhere in between, and the latched pick is untouched. +/// +/// benilla had no latch — every click consumer read the *live* hover, which +/// [`hover::update_hover`] clears outright the moment a look session engages. So the release found +/// an empty hover and [`click::select_on_click`] fell into its deselect arm: a drag didn't merely +/// fail to select, it would have **cleared** the target (decision 1122, ledger B226). +/// +/// Note this is a *different* thing from the hover highlight, which the reference **does** suppress +/// during freelook (`0x483e80` sets the WorldFrame cursor-suppress bit `[wf+0x38c]&2`, tested by the +/// hover classifier `0x4828d0`). Tooltip, brighten and cursor go quiet while you orbit; the pending +/// click's subject does not. Conflating those two was the root cause. +/// +/// Written by [`latch_press_pick`] at the head of the target chain, which runs **before** +/// [`hover::update_hover`] can clear anything, so the values it copies are the pick as of the press. +#[derive(Resource, Default, Clone, Copy)] +pub(crate) struct PressPick { + pub(crate) hovered: Hovered, + pub(crate) object: HoveredObject, + pub(crate) occlusion: PickOcclusion, + /// The cursor's Attack classification at the press — the reference's new-target validation + /// (`0x5ecb70`), which likewise reads the pick the gesture started on, not a live one. + pub(crate) attack: bool, +} + +/// Latch the frame's pick as a press begins — the [`PressPick`] writer. +/// +/// Ordered at the **head** of the target chain, ahead of every pick refresh, for a reason that is +/// easy to lose: `player::control` (input) has already run this frame, so on the press frame the +/// look session is *already* engaged and [`hover::update_hover`] — two systems below — is about to +/// clear the hover. The values still standing here are last frame's, computed at the same cursor +/// position the press landed on, because the cursor cannot have moved between the two. That is the +/// press-time pick, and it is the last moment it exists. +pub(crate) fn latch_press_pick( + buttons: Res>, + hovered: Res, + object: Res, + occlusion: Res, + cursor: Res, + mut press: ResMut, +) { + // Either primary button's down edge arms a pick, exactly as the reference's `0x514810` does for + // clickMode 1 (CameraOrSelectOrMove) and 2 (TurnOrAction). A chord — the second button of a + // both-button run — must NOT re-latch: `0x51481a` refuses to arm while another primary is held, + // and the arbiter cancels the pending click outright anyway. + let left = buttons.just_pressed(MouseButton::Left) && !buttons.pressed(MouseButton::Right); + let right = buttons.just_pressed(MouseButton::Right) && !buttons.pressed(MouseButton::Left); + if !(left || right) { + return; + } + *press = PressPick { + hovered: *hovered, + object: *object, + occlusion: *occlusion, + attack: cursor.kind == cursor_mode::CursorKind::Attack, + }; +} + /// Which of the two hovered CGObjects — the unit [`Hovered`] or the [`HoveredObject`] — is the /// nearer thing under the cursor, and so the one a click acts on. The reference makes one pick over /// all CGObjects and switches on type; benilla runs the two picks separately, then chooses the @@ -200,6 +264,7 @@ impl Plugin for TargetPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -223,7 +288,10 @@ impl Plugin for TargetPlugin { .add_systems( Update, ( - hover::update_pick_occlusion, + // Grouped as one element (the outer chain is at Bevy's 20-tuple limit), and + // ordered: the latch FIRST, ahead of every pick refresh, to freeze what the + // press was over while it still exists (decision 1122 — see [`latch_press_pick`]). + (latch_press_pick, hover::update_pick_occlusion).chain(), hover::update_hover, hover::update_hovered_object, cursor_mode::classify_cursor, @@ -319,4 +387,84 @@ mod tests { assert!(!go_is_nearest(&hov(true, 4.0), &obj(true, 10.0))); assert!(!go_is_nearest(&hov(true, 5.0), &obj(true, 5.0))); } + + /// The latch survives the hover being cleared — which is the whole reason it exists. + /// + /// A drag engages the look session, and `hover::update_hover` then blanks [`Hovered`] for its + /// whole duration (as the reference suppresses its own hover during freelook). Before decision + /// 1122 every click consumer read that live hover, so a click ending a drag saw *nothing* + /// hovered and [`click::select_on_click`] took its deselect arm: the gesture would have cleared + /// the player's target rather than selecting. The press latch is what a click reads instead. + #[test] + fn the_press_latch_outlives_the_hover_a_drag_clears() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + let id = world.register_system(latch_press_pick); + + // A boar under the cursor, and the cursor classified as Attack over it. + const BOAR: u64 = 0xB0A2; + world.resource_mut::().guid = Some(BOAR); + world.resource_mut::().kind = cursor_mode::CursorKind::Attack; + + // Press left. The latch takes the pick. + world + .resource_mut::>() + .press(MouseButton::Left); + world.run_system(id).unwrap(); + assert_eq!(world.resource::().hovered.guid, Some(BOAR)); + assert!(world.resource::().attack, "Attack rode along"); + + // The drag begins: the look session blanks the live hover, every frame, for as long as it + // lasts. The latch must not follow it down. + *world.resource_mut::() = Hovered::default(); + world.resource_mut::>().clear(); + world.run_system(id).unwrap(); + assert_eq!( + world.resource::().hovered.guid, + Some(BOAR), + "the release must still know what the press was over" + ); + } + + /// A chord — the second primary joining a held one — must not re-latch. The reference refuses + /// to arm or pick while another primary is down (`0x51481a`), and kills the pending click + /// outright (`0x514ac1`), so neither release of a both-button run can select. Re-latching here + /// would hand a spurious pick to a gesture that is only ever a camera turn. + #[test] + fn a_chord_does_not_relatch_the_pick() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + let id = world.register_system(latch_press_pick); + + const FIRST: u64 = 0x1111; + const SECOND: u64 = 0x2222; + world.resource_mut::().guid = Some(FIRST); + world + .resource_mut::>() + .press(MouseButton::Left); + world.run_system(id).unwrap(); + assert_eq!(world.resource::().hovered.guid, Some(FIRST)); + + // Right joins while left is still held, over something else entirely. + world.resource_mut::().guid = Some(SECOND); + let mut buttons = world.resource_mut::>(); + buttons.clear(); // drop last frame's just_pressed, keep `pressed` + buttons.press(MouseButton::Right); + world.run_system(id).unwrap(); + assert_eq!( + world.resource::().hovered.guid, + Some(FIRST), + "the chord's second press must not steal the latch" + ); + } } diff --git a/crates/benilla-app/src/ui_action/drop_item.rs b/crates/benilla-app/src/ui_action/drop_item.rs index 7ff47100..fbc2af4f 100644 --- a/crates/benilla-app/src/ui_action/drop_item.rs +++ b/crates/benilla-app/src/ui_action/drop_item.rs @@ -73,7 +73,7 @@ pub(crate) fn drop_item_on_unit( index: Res, pet_bar: Res, learned: Res, - hovered: Res, + press: Res, mut clicks: MessageReader, mut ladder: super::CastLadder, ) { @@ -81,9 +81,11 @@ pub(crate) fn drop_item_on_unit( clicks.clear(); return; }; + let hovered = press.hovered; let mut tokens = script.take_drop_item_on_unit(); - // The world entry: a left click whose pick IS the pet. `Hovered` is the same nearest-object - // pick the click itself selects from, so cursor and click agree by construction. + // The world entry: a left click whose pick IS the pet. The click's own press latch is the same + // nearest-object pick it selects from, so cursor and click agree by construction (1122: the + // *live* hover no longer does, since a click can end a gesture that orbited). let clicked_pet = clicks.read().count() > 0 && pet_bar.spells.pet_guid != 0 && hovered.guid == Some(pet_bar.spells.pet_guid); diff --git a/crates/benilla-app/src/ui_action/targeting/world.rs b/crates/benilla-app/src/ui_action/targeting/world.rs index c6027bc8..2752c15b 100644 --- a/crates/benilla-app/src/ui_action/targeting/world.rs +++ b/crates/benilla-app/src/ui_action/targeting/world.rs @@ -22,7 +22,9 @@ use bevy::prelude::*; use benilla_assets::coords::bevy_to_wow; use crate::interact::WorldClick; -use crate::target::{go_is_nearest, Hovered, HoveredObject, PickOcclusion}; +use crate::target::go_is_nearest; +#[cfg(test)] +use crate::target::{Hovered, HoveredObject}; use crate::ui_action::cast_send::TargetedBind; use super::TargetingWants; @@ -41,9 +43,12 @@ use super::TargetingWants; /// so the commit that clears it must come later in the same frame. pub(crate) fn commit_ground_cast_on_click( mut clicks: MessageReader, - occlusion: Res, + // The point the PRESS ray hit, not this frame's — the reference's `+0x360`, written by the one + // down-edge pick and read unchanged at the release (decision 1122). + press: Res, mut ladder: crate::ui_action::CastLadder, ) { + let occlusion = press.occlusion; if !ladder.ground.active() { // Keep the reader current so a click buffered while idle can never replay as a commit // the frame the mode turns on. @@ -106,10 +111,12 @@ pub(crate) fn commit_ground_cast_on_click( /// Runs AFTER `select_on_click`, like the terrain commit and for the same reason. pub(crate) fn commit_object_cast_on_click( mut clicks: MessageReader, - hovered: Res, - hovered_object: Res, + // The press's pick, as in the terrain leg — the object a gesture binds is the one it started + // on, whatever the mouse did after (decision 1122). + press: Res, mut ladder: crate::ui_action::CastLadder, ) { + let (hovered, hovered_object) = (press.hovered, press.object); if !ladder.ground.active() { // Reader hygiene, as in the terrain leg: a click buffered while idle must not replay as a // commit the frame the mode turns on. @@ -168,9 +175,8 @@ mod tests { world.init_resource::(); world.init_resource::>(); world.init_resource::>(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); + // The commit legs read the PRESS latch now (decision 1122), not the live hover. + world.init_resource::(); // REGISTERED, not `run_system_once`: this seam's reader hygiene is a property of state // that survives between frames, and a fresh system per call would start every read at // cursor 0 and hide exactly the bug the drain exists to prevent. @@ -178,13 +184,14 @@ mod tests { (world, rx, id) } - /// Put a GameObject under the cursor at `distance`, nearer than any unit. + /// Put a GameObject under the cursor at `distance`, nearer than any unit — in the press latch, + /// which is where a click's subject lives. fn hover_go(world: &mut World, distance: f32) { - world.insert_resource(HoveredObject { + world.resource_mut::().object = HoveredObject { target: Some(Entity::from_raw_u32(1).unwrap()), guid: Some(CHEST), distance, - }); + }; } fn click(world: &mut World, id: SystemId) { @@ -273,11 +280,11 @@ mod tests { .resource_mut::() .enter(OPENING, commit, LOCK_WORD); hover_go(&mut world, 9.0); - world.insert_resource(Hovered { + world.resource_mut::().hovered = Hovered { target: Some(Entity::from_raw_u32(2).unwrap()), guid: Some(0x1234), distance: 4.0, - }); + }; click(&mut world, id); assert!(rx.try_recv().is_err(), "a unit in front of the chest wins"); assert!(world.resource::().active());