From 2c4458ee1d4315f29114c7e13a2a01ce3b29a473 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:10:37 +0700 Subject: [PATCH] A jump on a moving lift measures its fall in the lift's frame (#1818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A jump on a descending lift (Undercity, Gnomeregan) played the hard-landing grunt and dust. While airborne, benilla carried the rider with the deck but kept the jump's start height in world coordinates. So the lift's drop during the arc, about 24 yd on Undercity's 29 yd/s cars, counted as a fall, and FALLINGFAR latched early. 1.12.1 measures the fall height (`0x7c60c0`) in the mover's own frame: transport-local while aboard, re-mapped when the mover boards or leaves (`0x7c6620`). `Player::carry_to` now moves the start height with the deck. A remote mover's arc is measured in the deck's frame when it takes off and lands on the same transport. Tests: a 60 fps jump on a deck dropping 28.95 yd/s lands with no fall height and no FALLINGFAR, for us (23.64 yd before) and through packets for a remote (23.88 yd before). Fixes #1250 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 560d7ff63e8394ca08c97a0867c852155f3e6b52) # Conflicts: # crates/benilla-app/src/spell/range_units.rs # docs/DEVIATIONS.md Absorbed into the octo fork. spell/range_units.rs is upstream-only (added after our base) and its change here was a test fixture's fall_start_z -> fall_start rename, so the file is dropped rather than orphaned; the rename's real call sites applied unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../src/creature_anim/select/tests.rs | 2 +- crates/benilla-app/src/net/motion/remote.rs | 57 +++++++++++---- crates/benilla-app/src/net/motion/spline.rs | 2 +- crates/benilla-app/src/net/motion/tests.rs | 51 ++++++++++--- crates/benilla-app/src/net/objects.rs | 6 +- crates/benilla-app/src/player/embody.rs | 2 +- crates/benilla-app/src/player/ride.rs | 71 ++++++++++++++++++- crates/benilla-app/src/player/state.rs | 2 +- 8 files changed, 163 insertions(+), 30 deletions(-) diff --git a/crates/benilla-app/src/creature_anim/select/tests.rs b/crates/benilla-app/src/creature_anim/select/tests.rs index b036807f..0ae69691 100644 --- a/crates/benilla-app/src/creature_anim/select/tests.rs +++ b/crates/benilla-app/src/creature_anim/select/tests.rs @@ -939,7 +939,7 @@ fn the_granted_modes_fold_into_the_flags_word_on_every_leg_but_our_own() { speed: 7.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], - fall_start_z: None, + fall_start: None, relay: Default::default(), last_apply_ms: 0.0, last_apply_pos: [0.0; 3], diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index e5a6aba2..b6b1b5dc 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -30,9 +30,9 @@ pub(crate) struct RemoteMotion { pub(crate) vertical_velocity: f32, /// The launch's `(cos, sin) * xyspeed`, frozen for the arc; `[0; 2]` on the ground. pub(crate) jump_xy_vel: [f32; 2], - /// The takeoff Z of this arc, `None` if the mover entered view airborne; on landing, - /// `fall_start_z - landing_z` is the fall height that gates the grunt and dust puff. - pub(crate) fall_start_z: Option, + /// This arc's takeoff height, `None` if the mover entered view airborne; on landing, its + /// drop to the landing is the fall height that gates the grunt and dust puff. + pub(crate) fall_start: Option, /// Not-yet-due relayed moves by fire-time, the reference's `CMovement+0x150` queue. pub(crate) pending: std::collections::VecDeque, pub(crate) relay: RelayChain, @@ -42,6 +42,35 @@ pub(crate) struct RemoteMotion { pub(crate) last_apply_pos: [f32; 3], } +/// A relayed pose's height in both frames its packet carries: the world's, and the deck's while +/// aboard (`ON_TRANSPORT`'s tail). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FallHeight { + pub(crate) world_z: f32, + /// The transport's guid and the deck-local z. + pub(crate) aboard: Option<(u64, f32)>, +} + +impl FallHeight { + fn of(ev: &RelayMove) -> Self { + Self { + world_z: ev.position[2], + aboard: ev.transport.as_ref().map(|t| (t.guid, t.pos.z)), + } + } + + /// The drop from `self` down to `landing`. Aboard one transport at both ends it is the deck + /// frame's, where a rider's `+0x7c` lives (`0x7c6900` re-seats it from the local pose), so a + /// moving lift adds nothing. Otherwise it is the world's: a takeoff aboard reads the deck at + /// takeoff, where the reference's leave (`0x7c6790`) maps `+0x7c` by the deck at the leave. + fn drop_to(self, landing: Self) -> f32 { + match (self.aboard, landing.aboard) { + (Some((from, start)), Some((to, end))) if from == to => start - end, + _ => self.world_z - landing.world_z, + } + } +} + /// What `unit_move` did with an inbound relayed move: the `out=` field of the `rly` trace, which /// logs discarded arrivals too. #[derive(Clone, Copy)] @@ -272,8 +301,8 @@ pub(in crate::net) fn apply_move( // The `FALLING` to grounded edge is a landing; its fall height gates the grunt and dust puff. let was_falling = rm.flags & FALLING != 0; let (new_start, descent) = - fall_arc_step(was_falling, now_falling, rm.fall_start_z, ev.position[2]); - rm.fall_start_z = new_start; + fall_arc_step(was_falling, now_falling, rm.fall_start, FallHeight::of(ev)); + rm.fall_start = new_start; if let Some(descent) = descent { landings.write(crate::creature_anim::HardLanding { entity: e, descent }); } @@ -545,19 +574,19 @@ pub(crate) fn jump_seed(jump: Option, fall_time: u32, feather: bool) - } } -/// One packet's step of the landing predictor: `(fall_start_z, descent)`, the descent +/// One packet's step of the landing predictor: `(fall_start, descent)`, the descent /// `takeoff - landing` only on the `FALLING` to grounded edge with a known takeoff. pub(in crate::net) fn fall_arc_step( was_falling: bool, now_falling: bool, - fall_start_z: Option, - packet_z: f32, -) -> (Option, Option) { + fall_start: Option, + packet: FallHeight, +) -> (Option, Option) { match (was_falling, now_falling) { - (false, true) => (Some(packet_z), None), // takeoff - (true, true) => (fall_start_z, None), // still airborne - (true, false) => (None, fall_start_z.map(|start| start - packet_z)), // landing edge - (false, false) => (None, None), // grounded + (false, true) => (Some(packet), None), // takeoff + (true, true) => (fall_start, None), // still airborne + (true, false) => (None, fall_start.map(|start| start.drop_to(packet))), // landing edge + (false, false) => (None, None), // grounded } } @@ -698,7 +727,7 @@ mod under_floor { speed: 0.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], - fall_start_z: None, + fall_start: None, pending: std::collections::VecDeque::new(), relay: RelayChain::default(), last_apply_ms: 0.0, diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 37aeaf23..fad00d88 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -1471,7 +1471,7 @@ mod server_moved_players { speed: 7.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], - fall_start_z: None, + fall_start: None, pending: std::collections::VecDeque::new(), relay: Default::default(), last_apply_ms: 0.0, diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index 9d0b2975..49065993 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -13,7 +13,7 @@ use crate::player::{GRAVITY, TERMINAL_VELOCITY}; use super::facing::resolve_facing; use super::relay::RelayChain; -use super::remote::{facing_lerp, fall_arc_step, jump_seed, reconcile_lerp}; +use super::remote::{facing_lerp, fall_arc_step, jump_seed, reconcile_lerp, FallHeight}; use super::spline::monster_move_spline; use super::{RemoteMotion, Spline}; @@ -28,20 +28,55 @@ fn speeds() -> MoveSpeeds { } } +/// A pose at world height `z`, off any transport. +fn at(z: f32) -> FallHeight { + FallHeight { + world_z: z, + aboard: None, + } +} + #[test] fn remote_fall_arc_reports_height_only_on_the_landing_edge() { - assert_eq!(fall_arc_step(false, true, None, 100.0), (Some(100.0), None)); assert_eq!( - fall_arc_step(true, true, Some(100.0), 80.0), - (Some(100.0), None) + fall_arc_step(false, true, None, at(100.0)), + (Some(at(100.0)), None) ); assert_eq!( - fall_arc_step(true, false, Some(100.0), 70.0), + fall_arc_step(true, true, Some(at(100.0)), at(80.0)), + (Some(at(100.0)), None) + ); + assert_eq!( + fall_arc_step(true, false, Some(at(100.0)), at(70.0)), (None, Some(30.0)) ); // Entered view mid-fall: no takeoff, no prediction. - assert_eq!(fall_arc_step(true, false, None, 70.0), (None, None)); - assert_eq!(fall_arc_step(false, false, None, 70.0), (None, None)); + assert_eq!(fall_arc_step(true, false, None, at(70.0)), (None, None)); + assert_eq!(fall_arc_step(false, false, None, at(70.0)), (None, None)); +} + +/// A remote's flat jump on Undercity's lift car 20652, descending 28.95 yd/s for the 0.825 s +/// arc: the deck-local heights measure it, not the world's 23.9 yd. +#[test] +fn a_remote_jump_on_a_descending_lift_lands_with_no_fall_height() { + const LIFT: u64 = 0x1fc0_0000_0000_0001; + let aboard = |world_z: f32| FallHeight { + world_z, + aboard: Some((LIFT, 0.4)), + }; + let (start, _) = fall_arc_step(false, true, None, aboard(100.0)); + let (_, descent) = fall_arc_step(true, false, start, aboard(100.0 - 28.95 * 0.825)); + let descent = descent.expect("a takeoff seen, a landing reported"); + assert!( + descent.abs() < 1e-6, + "no deck-frame drop, not the deck's {descent} yd" + ); + // Boarding at the landing, a jump from the world onto the deck measures in the world. + let (start, _) = fall_arc_step(false, true, None, at(120.0)); + assert_eq!( + fall_arc_step(true, false, start, aboard(100.0)), + (None, Some(20.0)) + ); } fn motion(flags: u32, orientation: f32) -> RemoteMotion { @@ -54,7 +89,7 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion { speed: 0.0, vertical_velocity: 0.0, jump_xy_vel: [0.0, 0.0], - fall_start_z: None, + fall_start: None, relay: Default::default(), last_apply_ms: 0.0, last_apply_pos: [0.0, 0.0, 0.0], diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index cc9d9589..0e7dc331 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -760,7 +760,7 @@ fn unit_move( speed: 0.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], - fall_start_z: None, + fall_start: None, pending: std::collections::VecDeque::new(), relay: Default::default(), last_apply_ms: now_ms, @@ -1036,7 +1036,7 @@ fn spline_move_mode( rm.speed = 0.0; rm.vertical_velocity = 0.0; rm.jump_xy_vel = [0.0; 2]; - rm.fall_start_z = None; + rm.fall_start = None; } } } @@ -1286,7 +1286,7 @@ mod tests { speed: 7.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], - fall_start_z: None, + fall_start: None, relay: Default::default(), last_apply_ms: 0.0, last_apply_pos: [0.0; 3], diff --git a/crates/benilla-app/src/player/embody.rs b/crates/benilla-app/src/player/embody.rs index 26284af0..7f452139 100644 --- a/crates/benilla-app/src/player/embody.rs +++ b/crates/benilla-app/src/player/embody.rs @@ -125,7 +125,7 @@ mod tests { speed: 0.0, vertical_velocity: 0.0, jump_xy_vel: [0.0; 2], - fall_start_z: None, + fall_start: None, pending: std::collections::VecDeque::new(), relay: Default::default(), last_apply_ms: 0.0, diff --git a/crates/benilla-app/src/player/ride.rs b/crates/benilla-app/src/player/ride.rs index 94027aaa..cc44c947 100644 --- a/crates/benilla-app/src/player/ride.rs +++ b/crates/benilla-app/src/player/ride.rs @@ -22,7 +22,7 @@ pub(super) fn carry(player: &mut Player, cam: &mut FlyCam, transports: &Transpor // `to_euler` wraps to (−π, π]; a boat crossing that seam reads as a ±2π hop. dyaw = (dyaw + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; - player.pos = world; + player.carry_to(world); player.face_yaw += dyaw; player.model_yaw = wrap_pi(player.model_yaw + dyaw); cam.yaw += dyaw; @@ -35,6 +35,17 @@ pub(super) fn carry(player: &mut Player, cam: &mut FlyCam, transports: &Transpor } } +impl Player { + /// Moves the rider to its deck-carried `world` feet, and the arc's launch height with it: a + /// rider's fall start `+0x7c` lives in the transport's frame (`0x7c6620` maps it at board and + /// leave), where the fall height (`0x7c60c0`) and FALLINGFAR (`0x633240`) measure it. A deck + /// pose is yaw-only, so the launch height moves by the feet's own vertical carry. + pub(super) fn carry_to(&mut self, world: Vec3) { + self.fall_start_y += world.y - self.pos.y; + self.pos = world; + } +} + /// Attaches or detaches against this frame's walkable support, then re-snapshots the deck-local /// pose. `ground` is the mover's resolved support ([`super::mover::Outcome::ground`]). pub(super) fn update_attachment( @@ -158,3 +169,61 @@ pub(super) fn update_attachment( } } } + +#[cfg(test)] +mod tests { + use super::super::mover::fall_step; + use super::super::{GRAVITY, JUMP_SPEED, TERMINAL_VELOCITY}; + use super::*; + + /// Undercity's lift car 20652 descends 96.5 yd in 3.33 s (`TransportAnimation.dbc`). + const LIFT_DROP_SPEED: f32 = 28.95; + + /// A 60 fps jump on a descending deck, carried every frame as `carry` does: the launch height + /// rides the deck, so the landing reads the deck-frame drop of a flat jump. + #[test] + fn a_jump_on_a_descending_lift_lands_with_no_fall_height() { + let dt = 1.0 / 60.0; + let mut deck_y = 100.0; + let mut p = Player { + pos: Vec3::new(0.0, deck_y, 0.0), + launch_vz: JUMP_SPEED, + ..Default::default() + }; + let (mut height, mut vz) = (0.0_f32, JUMP_SPEED); + let (mut latched, mut frames) = (false, 0); + let descent = loop { + if frames > 0 { + deck_y -= LIFT_DROP_SPEED * dt; + p.carry_to(Vec3::new(0.0, deck_y + height, 0.0)); + } + let launch_y = p.pos.y; + let (v1, mean) = fall_step(vz, dt, TERMINAL_VELOCITY); + vz = v1; + height += mean * dt; + let airborne = height > 0.0; + if !airborne { + height = 0.0; + } + p.pos.y = deck_y + height; + let edges = p.advance_airborne_arc(airborne, frames == 0, frames as f32 * dt, launch_y); + latched |= p.fall_far; + frames += 1; + if edges.landed { + break p.fall_start_y - p.pos.y; + } + assert!(frames < 120, "the arc lands"); + }; + let air_time = frames as f32 * dt; + assert!( + (air_time - 2.0 * JUMP_SPEED / GRAVITY).abs() < 2.0 * dt, + "a full flat arc, {air_time} s" + ); + assert!( + descent.abs() < 0.01, + "a flat jump drops nothing in the deck's frame, not the deck's {descent} yd" + ); + assert!(descent < crate::creature_anim::HARD_LANDING_DESCENT); + assert!(!latched, "nor does it latch FALLINGFAR"); + } +} diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 9bc7ad9e..219e0a8d 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -407,7 +407,7 @@ pub(crate) struct Player { /// live flags read them instead of the keys; re-seeded by the air nudge, stale while grounded. pub(super) airborne_dirs: u32, /// Launch height (Bevy Y), the client's `StartFalling` z snapshot (`+0x7c`): the FALLINGFAR - /// distance leg measures descent below it. + /// distance leg measures descent below it. A rider's moves with the deck ([`Self::carry_to`]). pub(super) fall_start_y: f32, /// `MOVEFLAG_FALLINGFAR` latched for this arc ([`FALL_FAR_DROP`], [`FALL_FAR_TIME`]); only /// landing clears it, as the client's `StopFalling` does.