- stealth reads as stealth now: the body fades to the reference's 30 % and creeps with the proper stealth walk and stand poses - the item and enchant glow subsystem exists: permanent weapon glows and enchant visuals show in the world and at character select - the character-select background's colour banding (the reported "16-bit" look) is fixed at the render target, and the tauren stage's black corners with it - aura body tints got a real per-instance channel, so a spell can tint the whole unit without touching its neighbours Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
904 lines
36 KiB
Rust
904 lines
36 KiB
Rust
//! Unit tests for the pure selection logic in [`super`] — moved to its own file as it carries the
|
||
//! bulk of [`super`]'s line count; the production code + this file together are the same single
|
||
//! `select` concern.
|
||
|
||
use super::*;
|
||
use bevy::animation::graph::AnimationNodeIndex;
|
||
|
||
fn moving_forward(speed: f32) -> MovementState {
|
||
MovementState {
|
||
speed,
|
||
flags: move_flags::FORWARD,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
fn clip(anim_id: u16, move_speed: f32) -> AnimClip {
|
||
AnimClip {
|
||
anim_id,
|
||
seq_index: 0,
|
||
node: AnimationNodeIndex::new(0),
|
||
looping: true,
|
||
duration: 1.0,
|
||
move_speed,
|
||
blend_time: 0.25,
|
||
bounds_center: Vec3::ZERO,
|
||
bounds_radius: 0.0,
|
||
bounds_min: Vec3::ZERO,
|
||
bounds_max: Vec3::ZERO,
|
||
events: Vec::new().into(),
|
||
arm_nodes: None,
|
||
upper_node: None,
|
||
frequency: 0,
|
||
replay: (0, 0),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stationary_is_stand() {
|
||
assert_eq!(
|
||
gait_candidates(&MovementState::default(), 2.5, None, None),
|
||
&[STAND]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn walk_run_boundary_is_twice_walk_speed() {
|
||
assert_eq!(gait_candidates(&moving_forward(4.9), 2.5, None, None)[0], 4);
|
||
assert_eq!(gait_candidates(&moving_forward(5.0), 2.5, None, None)[0], 4); // exactly 2× is Walk
|
||
assert_eq!(gait_candidates(&moving_forward(5.1), 2.5, None, None)[0], 5);
|
||
}
|
||
|
||
#[test]
|
||
fn boundary_scales_with_the_units_own_walk_speed() {
|
||
assert_eq!(gait_candidates(&moving_forward(7.0), 4.0, None, None)[0], 4);
|
||
assert_eq!(gait_candidates(&moving_forward(7.0), 2.5, None, None)[0], 5);
|
||
}
|
||
|
||
#[test]
|
||
fn fast_run_above_eleven() {
|
||
assert_eq!(
|
||
gait_candidates(&moving_forward(11.0), 2.5, None, None),
|
||
&[143, 5, 4, 0]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn backward_is_walkbackwards() {
|
||
let s = MovementState {
|
||
speed: 9.0,
|
||
flags: move_flags::BACKWARD | move_flags::STRAFE_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&s, 2.5, None, None), &[13, 4, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn swimming_back_forward_strafe_and_idle() {
|
||
// The RF-0057 swim row (`0x5fd137`…): fwd/back/turn/strafe → 42/45/41/43-44. SwimLeft 43 /
|
||
// SwimRight 44 byte-read from AnimationData.dbc; the combined-bit cascade below is the
|
||
// VERIFIED TU-E order (wow-re `swim-mechanism.md`).
|
||
let back = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::BACKWARD,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&back, 2.5, None, None), &[45, 41, 0]);
|
||
let fwd = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::FORWARD,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&fwd, 2.5, None, None), &[42, 41, 0]);
|
||
let left = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::STRAFE_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&left, 2.5, None, None), &[43, 42, 41, 0]);
|
||
let right = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::STRAFE_RIGHT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&right, 2.5, None, None), &[44, 42, 41, 0]);
|
||
// The VERIFIED `0x5fd100` cascade (TU-E): TURN > STRAFE > BACKWARD > FORWARD. A strafe
|
||
// diagonal plays the side-stroke — strafe outranks both fwd and back…
|
||
let diag = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::FORWARD | move_flags::STRAFE_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&diag, 2.5, None, None), &[43, 42, 41, 0]);
|
||
let back_diag = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::BACKWARD | move_flags::STRAFE_RIGHT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(
|
||
gait_candidates(&back_diag, 2.5, None, None),
|
||
&[44, 42, 41, 0]
|
||
);
|
||
// …fwd+back together resolve backward…
|
||
let fwd_back = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::FORWARD | move_flags::BACKWARD,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&fwd_back, 2.5, None, None), &[45, 41, 0]);
|
||
// …and a TURNING swimmer treads water whatever its travel bits (turn outranks all).
|
||
let turn_moving = MovementState {
|
||
speed: 4.0,
|
||
flags: move_flags::SWIMMING | move_flags::FORWARD | move_flags::TURN_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&turn_moving, 2.5, None, None), &[41, 0]);
|
||
let turn = MovementState {
|
||
flags: move_flags::SWIMMING | move_flags::TURN_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&turn, 2.5, None, None), &[41, 0]);
|
||
let idle = MovementState {
|
||
flags: move_flags::SWIMMING,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&idle, 2.5, None, None), &[41, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn airborne_splits_jump_fall_and_gait_freeze() {
|
||
// The three-way airborne split (wow-re land-anim-height-gate): a jump arc plays the 37/38
|
||
// bracket; FALLINGFAR latched → the Fall(40) loop (whatever the arc's origin); a step-off
|
||
// fall below the latch is NO special — the gait freezes through it (keep-current).
|
||
let s = MovementState {
|
||
flags: move_flags::FORWARD | move_flags::FALLING,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(current_special(&s, true), Some(Special::Jump));
|
||
assert_eq!(current_special(&s, false), None);
|
||
let far = MovementState {
|
||
flags: move_flags::FORWARD | move_flags::FALLING | move_flags::FALLING_FAR,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(current_special(&far, true), Some(Special::Fall));
|
||
assert_eq!(current_special(&far, false), Some(Special::Fall));
|
||
}
|
||
|
||
#[test]
|
||
fn fall_is_the_40_loop() {
|
||
// Fall(40) both ways — `enter_special` goes straight to the loop (no enter one-shot).
|
||
assert_eq!(Special::Fall.enter(), 40);
|
||
assert_eq!(Special::Fall.loop_id(), 40);
|
||
assert!(!Special::Fall.interruptible_by_move());
|
||
}
|
||
|
||
#[test]
|
||
fn a_pose_yields_to_movement_but_a_jump_landing_plays_out() {
|
||
// A pose's stand-up is cut short the instant the unit moves; a jump's landing footplant isn't.
|
||
assert!(Special::Pose(1).interruptible_by_move());
|
||
assert!(Special::Pose(3).interruptible_by_move());
|
||
assert!(Special::Pose(8).interruptible_by_move());
|
||
assert!(!Special::Jump.interruptible_by_move());
|
||
}
|
||
|
||
#[test]
|
||
fn jump_sequence_ids() {
|
||
// The §5/asset-verified sequence: JumpStart 37 → Jump 38 → the landing pick (below).
|
||
assert_eq!(Special::Jump.enter(), 37);
|
||
assert_eq!(Special::Jump.loop_id(), 38);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_land_pick_is_the_0x602c60_rule() {
|
||
// The land dispatcher `0x602c60` (wow-re rf57b §2): stopped → JumpEnd 39; moving forward or
|
||
// strafing → JumpLandRun 187; backpedaling or walk-mode → NO landing clip (the recompute drops
|
||
// straight into the gait — a jump-then-hold-S backpedals the instant it touches down, never
|
||
// flashing the forward-run footplant); swimming → no clip (the swim gait takes over).
|
||
use move_flags::*;
|
||
assert_eq!(jump_land_pick(0), Some(39));
|
||
assert_eq!(jump_land_pick(FORWARD), Some(187));
|
||
assert_eq!(jump_land_pick(STRAFE_LEFT), Some(187));
|
||
assert_eq!(jump_land_pick(FORWARD | STRAFE_RIGHT), Some(187));
|
||
assert_eq!(jump_land_pick(BACKWARD), None);
|
||
assert_eq!(jump_land_pick(BACKWARD | STRAFE_LEFT), None);
|
||
assert_eq!(jump_land_pick(FORWARD | WALK_MODE), None);
|
||
assert_eq!(jump_land_pick(SWIMMING), None);
|
||
assert_eq!(jump_land_pick(FORWARD | SWIMMING), None);
|
||
}
|
||
|
||
#[test]
|
||
fn standstate_is_a_pose_special_only_while_still() {
|
||
let sit = MovementState {
|
||
stand_state: 1,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(current_special(&sit, false), Some(Special::Pose(1)));
|
||
// SitDown 96 → Sit 97 → SitUp 98.
|
||
assert_eq!(Special::Pose(1).enter(), 96);
|
||
assert_eq!(Special::Pose(1).loop_id(), 97);
|
||
assert_eq!(Special::Pose(1).exit(), 98);
|
||
// Sleep / Kneel triples.
|
||
assert_eq!(
|
||
(Special::Pose(3).enter(), Special::Pose(3).loop_id()),
|
||
(99, 100)
|
||
);
|
||
assert_eq!(
|
||
(Special::Pose(8).enter(), Special::Pose(8).loop_id()),
|
||
(114, 115)
|
||
);
|
||
// Moving suppresses the pose (you stand up to move).
|
||
let sit_moving = MovementState {
|
||
speed: 3.0,
|
||
flags: move_flags::FORWARD,
|
||
stand_state: 1,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(current_special(&sit_moving, false), None);
|
||
assert_eq!(gait_candidates(&sit_moving, 2.5, None, None), &[4, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn turn_in_place_shuffles_but_moving_turn_runs() {
|
||
// Turning the facing with no translation plays the foot-shuffle (11/12).
|
||
let left = MovementState {
|
||
flags: move_flags::TURN_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&left, 2.5, None, None), &[11, 0]);
|
||
let right = MovementState {
|
||
flags: move_flags::TURN_RIGHT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&right, 2.5, None, None), &[12, 0]);
|
||
// Turning *while* moving runs (the path curves) — not the in-place shuffle.
|
||
let move_turn = MovementState {
|
||
speed: 3.0,
|
||
flags: move_flags::FORWARD | move_flags::TURN_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(gait_candidates(&move_turn, 2.5, None, None), &[4, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn sheath_clip_is_the_0x88_test() {
|
||
// The byte-verified pick: sheathe types 3 and 7 → HipSheath(90); everything else → 89.
|
||
assert_eq!(sheath_clip(3), 90);
|
||
assert_eq!(sheath_clip(7), 90);
|
||
for t in [0, 1, 2, 4, 5, 6, 8] {
|
||
assert_eq!(sheath_clip(t), 89, "type {t}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn swing_ids_by_weapon_class() {
|
||
// The byte-verified 0x6246a0 mainhand table (decision 0073).
|
||
assert_eq!(swing_anim_main(Some((2, 7))), 17); // 1H sword
|
||
assert_eq!(swing_anim_main(Some((2, 5))), 18); // 2H mace
|
||
assert_eq!(swing_anim_main(Some((2, 0xa))), 19); // staff
|
||
assert_eq!(swing_anim_main(Some((2, 0x14))), 19); // fishing pole
|
||
assert_eq!(swing_anim_main(Some((2, 0xf))), 85); // dagger stabs, not Attack1H
|
||
assert_eq!(swing_anim_main(Some((2, 0xd))), 16); // fist swings unarmed
|
||
assert_eq!(swing_anim_main(Some((2, 2))), 16); // bow in melee swings unarmed
|
||
assert_eq!(swing_anim_main(Some((4, 6))), 16); // non-weapon class
|
||
assert_eq!(swing_anim_main(None), 16);
|
||
// Offhand (HitInfo & 0x4): dagger pierces, weapons swing, empty punches.
|
||
assert_eq!(swing_anim_off(Some((2, 0xf))), 88);
|
||
assert_eq!(swing_anim_off(Some((2, 0))), 87);
|
||
assert_eq!(swing_anim_off(Some((4, 6))), 117);
|
||
assert_eq!(swing_anim_off(None), 117);
|
||
}
|
||
|
||
#[test]
|
||
fn ready_ids_bucket_differently_from_swings() {
|
||
// Fist AND dagger ready as 1H (decision 0073's 0x5fcdc0 table) though they swing 16/85.
|
||
assert_eq!(ready_anim(Some((2, 0xd))), 26);
|
||
assert_eq!(ready_anim(Some((2, 0xf))), 26);
|
||
assert_eq!(ready_anim(Some((2, 8))), 27); // 2H sword
|
||
assert_eq!(ready_anim(Some((2, 6))), 28); // polearm
|
||
assert_eq!(ready_anim(Some((2, 2))), 25); // bow
|
||
assert_eq!(ready_anim(None), 25);
|
||
}
|
||
|
||
#[test]
|
||
fn ready_idle_only_while_standing() {
|
||
// Standing + engaged → the Ready idle (with the unarmed + Stand fallbacks).
|
||
assert_eq!(
|
||
gait_candidates(&MovementState::default(), 2.5, Some(26), None),
|
||
&[26, 25, 0]
|
||
);
|
||
// The caller suppresses ready while moving — locomotion always outranks it.
|
||
assert_eq!(gait_candidates(&moving_forward(3.0), 2.5, None, None)[0], 4);
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_priority_is_stow_over_draw() {
|
||
// Flag &4 stows unconditionally — even engaged (swimming mid-combat stows).
|
||
assert_eq!(reconcile_sheath(1, 42, 4, true, true, 1, false), Some(0));
|
||
// Flag &0x10 stows BEFORE the engaged draw: an engaged bare-fist unit playing
|
||
// ReadyUnarmed/AttackUnarmed (0x10) stows — fists need empty hands.
|
||
assert_eq!(reconcile_sheath(1, 25, 0x10, true, true, 1, false), Some(0));
|
||
// Engaged draws melee even on a flagless clip (the persistent engaged re-assert).
|
||
assert_eq!(reconcile_sheath(0, 0, 0, true, true, 0, false), Some(1));
|
||
// Flag &0x20 draws without engagement (readying, fishing).
|
||
assert_eq!(
|
||
reconcile_sheath(0, 133, 0x20, false, true, 0, false),
|
||
Some(1)
|
||
);
|
||
// No flags, not engaged: the local player is left alone (a manual toggle persists).
|
||
assert_eq!(reconcile_sheath(1, 0, 0, false, true, 0, false), None);
|
||
assert_eq!(reconcile_sheath(0, 0, 0, false, true, 1, false), None);
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_mounted_is_a_persistent_draw_block() {
|
||
// Mounted forces stow on every recompute (decision 0441, wow-re sheath-policy §3): it beats
|
||
// the engaged draw, the &0x20 draw, and the remote server-byte pull-through alike — a
|
||
// volunteered drawn byte can never re-arm a rider.
|
||
assert_eq!(reconcile_sheath(1, 0, 0, true, true, 1, true), Some(0));
|
||
assert_eq!(
|
||
reconcile_sheath(1, 133, 0x20, false, true, 1, true),
|
||
Some(0)
|
||
);
|
||
assert_eq!(reconcile_sheath(1, 0, 0, false, false, 1, true), Some(0));
|
||
// Already stowed: the force is idempotent (the caller's `forced != cur` gate skips the write).
|
||
assert_eq!(reconcile_sheath(0, 0, 0, false, true, 0, true), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_ranged_exemption_and_remote_pull_through() {
|
||
// Ranged-drawn: the 0x5fe180 predicate's nine ids — the ranged Load/Hold/Attack family —
|
||
// are exempt from the &0x10 stow (byte-verified set, see the const's doc)…
|
||
for anim in [46, 49, 105, 106, 107, 109, 110, 111, 112] {
|
||
assert_eq!(reconcile_sheath(2, anim, 0x10, false, true, 2, false), None);
|
||
}
|
||
// …ReadyThrown 108 is NOT exempt — it genuinely stows; the thrown wind-up survives via
|
||
// the driver's ranged-hold snap bracket, not an exemption…
|
||
assert_eq!(
|
||
reconcile_sheath(2, 108, 0x10, false, true, 2, false),
|
||
Some(0)
|
||
);
|
||
// …but any other &0x10 clip stows even while ranged-drawn (an emote lowers the bow)…
|
||
assert_eq!(
|
||
reconcile_sheath(2, 60, 0x10, false, true, 2, false),
|
||
Some(0)
|
||
);
|
||
// …and while ranged-drawn the melee draw rules don't apply (no engaged→1 overwrite).
|
||
assert_eq!(reconcile_sheath(2, 0, 0, true, true, 2, false), None);
|
||
// A remote unit with no force pulls back to the server byte (swim ends → redraw)…
|
||
assert_eq!(reconcile_sheath(0, 0, 0, false, false, 1, false), Some(1));
|
||
// …but the local player's committed state is never server-reconciled.
|
||
assert_eq!(reconcile_sheath(0, 0, 0, false, true, 1, false), None);
|
||
}
|
||
|
||
#[test]
|
||
fn backpedal_rate_speeds_up_a_slow_design_speed() {
|
||
// Backpedaling at 4.5 yd/s against WalkBack authored for 2.5 → 1.8× (the "too slow" fix).
|
||
assert!((playback_rate(&clip(13, 2.5), 4.5) - 1.8).abs() < 1e-5);
|
||
assert!((playback_rate(&clip(5, 7.0), 7.0) - 1.0).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn non_locomotion_clips_play_at_unit_rate() {
|
||
assert_eq!(playback_rate(&clip(0, 0.0), 9.0), 1.0); // idle
|
||
assert_eq!(playback_rate(&clip(38, 0.0), 9.0), 1.0); // jump hang (moveSpeed 0)
|
||
assert_eq!(playback_rate(&clip(60, 2.0), 9.0), 1.0); // an id outside the scaled set
|
||
}
|
||
|
||
#[test]
|
||
fn ranged_load_idle_selects_by_weapon_and_ranks_below_ready() {
|
||
// The 0x5fd530 LUT (0099 phase 5): ranged-slot subclass → the held Load/Hold clip.
|
||
assert_eq!(ranged_load_anim(Some((2, 2))), 105); // Bow → LoadBow
|
||
assert_eq!(ranged_load_anim(Some((2, 3))), 106); // Gun → LoadRifle
|
||
assert_eq!(ranged_load_anim(Some((2, 18))), 106); // Crossbow → LoadRifle
|
||
assert_eq!(ranged_load_anim(Some((2, 16))), 112); // Thrown → LoadThrown
|
||
assert_eq!(ranged_load_anim(Some((2, 19))), 111); // Wand → HoldThrown
|
||
assert_eq!(ranged_load_anim(None), 25); // empty ranged slot → ReadyUnarmed
|
||
assert_eq!(ranged_load_anim(Some((4, 1))), 25); // a non-weapon item → ReadyUnarmed
|
||
|
||
// Standing with the idle armed: its own candidate arm, ReadyUnarmed the model fallback.
|
||
let standing = MovementState::default();
|
||
assert_eq!(
|
||
gait_candidates(&standing, 2.5, None, Some(105)),
|
||
&[105, 25, 0]
|
||
);
|
||
// The engaged melee Ready outranks it (they can't co-occur in the client — auto-shot never
|
||
// sets the engaged GUID — but the ordering is pinned here anyway).
|
||
assert_eq!(gait_candidates(&standing, 2.5, Some(26), Some(105))[0], 26);
|
||
// Locomotion outranks it: a moving shooter runs, the idle waits for the stop.
|
||
assert_eq!(
|
||
gait_candidates(&moving_forward(3.0), 2.5, None, Some(105))[0],
|
||
4
|
||
);
|
||
// It fills the bare-Stand slot's rank — so it also blocks the state-emote idle.
|
||
assert!(!is_bare_stand(gait_candidates(
|
||
&standing,
|
||
2.5,
|
||
None,
|
||
Some(105)
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn state_emote_idle_only_fills_the_bare_stand_slot() {
|
||
// Standing, nothing else going on: the one slot the state-emote idle may fill.
|
||
assert!(is_bare_stand(gait_candidates(
|
||
&MovementState::default(),
|
||
2.5,
|
||
None,
|
||
None
|
||
)));
|
||
assert_eq!(state_emote_gait(200), [200, STAND]);
|
||
|
||
// Movement outranks it — its own candidate array, not bare Stand.
|
||
assert!(!is_bare_stand(gait_candidates(
|
||
&moving_forward(3.0),
|
||
2.5,
|
||
None,
|
||
None
|
||
)));
|
||
// Turning in place outranks it too.
|
||
let turning = MovementState {
|
||
flags: move_flags::TURN_LEFT,
|
||
..Default::default()
|
||
};
|
||
assert!(!is_bare_stand(gait_candidates(&turning, 2.5, None, None)));
|
||
// Swimming idle outranks it.
|
||
let swimming = MovementState {
|
||
flags: move_flags::SWIMMING,
|
||
..Default::default()
|
||
};
|
||
assert!(!is_bare_stand(gait_candidates(&swimming, 2.5, None, None)));
|
||
// Engaged (the Ready idle) outranks it.
|
||
assert!(!is_bare_stand(gait_candidates(
|
||
&MovementState::default(),
|
||
2.5,
|
||
Some(26),
|
||
None
|
||
)));
|
||
// A chair-loop stand-state (server 4/5/6) already has its own slot, ranked above bare Stand.
|
||
for stand_state in [4, 5, 6] {
|
||
let chair = MovementState {
|
||
stand_state,
|
||
..Default::default()
|
||
};
|
||
assert!(!is_bare_stand(gait_candidates(&chair, 2.5, None, None)));
|
||
}
|
||
}
|
||
|
||
// ── The per-play one-shot route (decision 0087) — one test per director-signed acceptance row.
|
||
// Ids: Attack1H 17 (a combat swing), EmoteApplaud 80 / EmoteBow 66 (waist-up-authored emotes),
|
||
// EmoteCheer 68 (full-body-authored emote). `stand_state` 1 = seated.
|
||
use OneShotRoute::{FullBody, Masked};
|
||
|
||
#[test]
|
||
fn route_row1_standing_swing_is_full_body() {
|
||
// Not moving, standState 0, combat(17) but not airborne → esi=0 → bone 0 (legs lunge, authored).
|
||
assert_eq!(route_oneshot(17, 0, 0), FullBody);
|
||
}
|
||
|
||
#[test]
|
||
fn route_row2_running_swing_is_masked() {
|
||
// Moving ([9e8] & 0x20003f) → esi=1 → SpineLow overlay; legs keep the run underneath.
|
||
assert_eq!(route_oneshot(17, move_flags::FORWARD, 0), Masked);
|
||
// Every direction bit — and the keyboard turn keys folded into 0x3f — commits the legs.
|
||
for f in [
|
||
move_flags::BACKWARD,
|
||
move_flags::STRAFE_LEFT,
|
||
move_flags::TURN_LEFT,
|
||
move_flags::TURN_RIGHT,
|
||
move_flags::SWIMMING,
|
||
] {
|
||
assert_eq!(route_oneshot(17, f, 0), Masked, "flag {f:#x}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn route_row3_midair_swing_is_masked() {
|
||
// Straight-up jump: not moving, standState 0, but combat(17) && airborne → esi=1 → overlay.
|
||
assert_eq!(route_oneshot(17, move_flags::FALLING, 0), Masked);
|
||
// The airborne test is COMBAT-gated: a non-combat emote mid-jump is NOT masked on air alone.
|
||
assert_eq!(route_oneshot(68, move_flags::FALLING, 0), FullBody);
|
||
}
|
||
|
||
#[test]
|
||
fn route_row4_seated_emote_is_masked() {
|
||
// standState ≠ 0 (seated) → esi=1 → overlay over the continuing Sit; /clap and /laugh alike.
|
||
assert_eq!(route_oneshot(80, 0, 1), Masked);
|
||
assert_eq!(route_oneshot(70, 0, 1), Masked); // EmoteLaugh family
|
||
}
|
||
|
||
#[test]
|
||
fn route_row5_standing_clap_and_bow_are_full_body() {
|
||
// Not moving, standState 0, combat NO (66/80 ∉ COMBAT) → esi=0 → bone 0. The waist-up LOOK is
|
||
// asset authoring (clap ≤1.3°, bow feet 0°), not a route special-case — same route as /cheer.
|
||
assert_eq!(route_oneshot(80, 0, 0), FullBody);
|
||
assert_eq!(route_oneshot(66, 0, 0), FullBody);
|
||
}
|
||
|
||
#[test]
|
||
fn route_row6_standing_cheer_is_full_body() {
|
||
// Identical routing to row 5 (68 ∉ COMBAT, standing) → bone 0; cheer's legs are authored large.
|
||
assert_eq!(route_oneshot(68, 0, 0), FullBody);
|
||
}
|
||
|
||
#[test]
|
||
fn route_row7_seated_cheer_is_masked() {
|
||
// Same cheer clip as row 6 but seated → esi=1 → overlay; the authored legs never reach the
|
||
// legs (overlay ⊅ legs), so the character neither stands nor moves its legs. The #6-vs-#7
|
||
// clincher: one clip, opposite looks, purely from the state route.
|
||
assert_eq!(route_oneshot(68, 0, 1), Masked);
|
||
}
|
||
|
||
#[test]
|
||
fn route_land_row8_picks_land_clip_from_touchdown_input() {
|
||
// Row 8 is a state-machine behavior (driven in `drive_animations`'s `Mode::Land` arm); its
|
||
// one-shot ids come from the `0x602c60` pick: JumpEnd 39 stopped, JumpLandRun 187 moving
|
||
// forward, no clip at all backpedaling/walking.
|
||
assert_eq!(jump_land_pick(0), Some(39));
|
||
assert_eq!(jump_land_pick(move_flags::FORWARD), Some(187));
|
||
assert_eq!(jump_land_pick(move_flags::BACKWARD), None);
|
||
// The land is not a non-preemptible bracket — `Mode::Land` re-picks on any flag change.
|
||
assert!(!Special::Jump.interruptible_by_move());
|
||
}
|
||
|
||
#[test]
|
||
fn route_classifier_memberships_are_the_decoded_bytes() {
|
||
// The load-bearing byte-decoded memberships (wow-re §3): 17 ∈ COMBAT, 66/68/80 ∉ COMBAT;
|
||
// 17/66/68/80 ∈ CLASS_A. All swing ids are COMBAT; no emote id is.
|
||
assert!(is_combat(17));
|
||
for id in [66, 68, 80] {
|
||
assert!(!is_combat(id), "emote {id} must not be COMBAT");
|
||
}
|
||
for id in [16, 17, 18, 19, 85, 87, 88, 117] {
|
||
assert!(is_combat(id) && is_class_a(id), "swing {id}");
|
||
}
|
||
for id in [17, 66, 68, 80] {
|
||
assert!(is_class_a(id), "class-A {id}");
|
||
}
|
||
// Forced-full-body carve-outs never mask, even seated.
|
||
for id in [1, 6, 131, 132, 57, 58, 118] {
|
||
assert_eq!(route_oneshot(id, 0, 1), FullBody, "forced full-body {id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn state_emote_idle_never_reached_during_a_special() {
|
||
// Jump and the sit/sleep/kneel poses are gated by `current_special` *before*
|
||
// `gait_candidates` is ever consulted (see `drive_animations`'s Gait arm) — the
|
||
// state-emote idle can't leak into either, so this pins the exhaustive precedence:
|
||
// Special > (movement/turn/swim/ready/chair, all non-bare-Stand) > state-emote idle > Stand.
|
||
let jumping = MovementState {
|
||
flags: move_flags::FALLING,
|
||
..Default::default()
|
||
};
|
||
assert!(current_special(&jumping, true).is_some());
|
||
for stand_state in [1u8, 3, 8] {
|
||
let posed = MovementState {
|
||
stand_state,
|
||
..Default::default()
|
||
};
|
||
assert!(current_special(&posed, false).is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn strafe_body_offset_matches_the_client_sign_fold() {
|
||
use move_flags::{BACKWARD, FORWARD, STRAFE_LEFT, STRAFE_RIGHT};
|
||
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
|
||
// Pure strafe: ±90°, left-positive.
|
||
assert_eq!(strafe_body_offset(STRAFE_LEFT), FRAC_PI_2);
|
||
assert_eq!(strafe_body_offset(STRAFE_RIGHT), -FRAC_PI_2);
|
||
// Forward diagonal: ±45°.
|
||
assert_eq!(strafe_body_offset(STRAFE_LEFT | FORWARD), FRAC_PI_4);
|
||
assert_eq!(strafe_body_offset(STRAFE_RIGHT | FORWARD), -FRAC_PI_4);
|
||
// Backpedal diagonal mirrors (the client's `((flags>>1)&2)==(flags&2)` fold): a back-left
|
||
// diagonal faces the body forward-RIGHT and backpedals along the movement line.
|
||
assert_eq!(strafe_body_offset(STRAFE_LEFT | BACKWARD), -FRAC_PI_4);
|
||
assert_eq!(strafe_body_offset(STRAFE_RIGHT | BACKWARD), FRAC_PI_4);
|
||
// Not strafing (or both strafe keys cancelling): no offset.
|
||
assert_eq!(strafe_body_offset(FORWARD), 0.0);
|
||
assert_eq!(strafe_body_offset(0), 0.0);
|
||
assert_eq!(strafe_body_offset(STRAFE_LEFT | STRAFE_RIGHT), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn strafe_flip_always_swings_around_the_front() {
|
||
use std::f32::consts::{FRAC_PI_2, PI};
|
||
// The left↔right flip is an exact 180° turn: easing the absolute yaw ties at the shortest-arc
|
||
// wrap and float noise picks a side — the reported "sometimes it spins around the back".
|
||
// Offset-space easing must swing through the aim (the front): the aim-relative offset stays
|
||
// within ±90° for the whole transition, both flip directions, at any aim (including one near
|
||
// the ±π wrap seam, where the absolute yaws cross the discontinuity mid-swing).
|
||
for aim in [0.0_f32, 1.3, PI - 0.01, -2.6] {
|
||
for (from, to) in [(-FRAC_PI_2, FRAC_PI_2), (FRAC_PI_2, -FRAC_PI_2)] {
|
||
// Bit-exactly converged on the old pose — the knife-edge case.
|
||
let mut yaw = super::super::wrap_pi(aim + from);
|
||
for _ in 0..120 {
|
||
yaw = ease_strafe_yaw(yaw, aim, to, 1.0 / 60.0);
|
||
let off = super::super::wrap_pi(yaw - aim);
|
||
assert!(
|
||
off.abs() <= FRAC_PI_2 + 1e-4,
|
||
"left the front arc: aim {aim}, {from}→{to}, offset {off}"
|
||
);
|
||
}
|
||
let end = super::super::wrap_pi(yaw - aim);
|
||
assert!((end - to).abs() < 1e-2, "did not converge: {end} vs {to}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn base_arm_head_force_is_the_combat_carveout() {
|
||
// Decision 0123 (the client's `0x5fdba0` re-zero gate): a relaxed arm rolls (−1)…
|
||
assert!(!arm_forces_head(false, false, 0), "Stand → Stand re-arm");
|
||
assert!(
|
||
!arm_forces_head(false, false, 11),
|
||
"Shuffle → Stand re-arm — the fidget trigger"
|
||
);
|
||
assert!(
|
||
!arm_forces_head(false, false, 60),
|
||
"an emote falling back to Stand"
|
||
);
|
||
// …and engagement, a live cast hold, or a combat/cast/ready outgoing id force the head.
|
||
assert!(arm_forces_head(true, false, 0), "auto-attack target set");
|
||
assert!(arm_forces_head(false, true, 0), "cast/channel hold");
|
||
assert!(arm_forces_head(false, false, 17), "outgoing Attack1H");
|
||
assert!(arm_forces_head(false, false, 26), "outgoing Ready1H");
|
||
assert!(
|
||
arm_forces_head(false, false, 53),
|
||
"outgoing SpellCastDirected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wound_id_by_severity_then_engagement() {
|
||
// Decision 0111 §5.3: crit outranks engagement; engagement decides 9 vs 8.
|
||
assert_eq!(wound_anim(0x2 | 0x80, false), 10);
|
||
assert_eq!(wound_anim(0x2 | 0x80, true), 10);
|
||
assert_eq!(wound_anim(0x2, true), 9);
|
||
assert_eq!(wound_anim(0x2, false), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn wound_route_full_body_on_ready_stance_or_stationary_standwound() {
|
||
use move_flags::*;
|
||
// (A) any id over a combat-ready base pose {25–29} — regardless of move flags (the client
|
||
// tests the bone-0 armed record alone).
|
||
assert!(wound_full_body(9, 26, 0, false));
|
||
assert!(wound_full_body(10, 29, FORWARD, false));
|
||
// (B) StandWound, genuinely stationary. The client's 0x20200f mask has no turn bits: a
|
||
// keyboard turn-in-place does NOT block the full-body route.
|
||
assert!(wound_full_body(8, 0, 0, false));
|
||
assert!(wound_full_body(8, 0, TURN_LEFT, false));
|
||
assert!(!wound_full_body(8, 0, FORWARD, false));
|
||
assert!(!wound_full_body(8, 0, FALLING, false));
|
||
assert!(!wound_full_body(8, 0, SWIMMING, false));
|
||
// Masked otherwise: engaged victim mid-its-own-swing (base = the swing, not ready), a moving
|
||
// CombatWound, a stationary CombatWound over a non-ready base (unarmed idle).
|
||
assert!(!wound_full_body(9, 17, 0, false));
|
||
assert!(!wound_full_body(9, 5, FORWARD, false));
|
||
assert!(!wound_full_body(9, 0, 0, false));
|
||
// Mounted masks the stationary StandWound (decision 0441, the `[unit+0xdc]==0` companion
|
||
// clause): a rider's flinch never replaces the seat pose.
|
||
assert!(!wound_full_body(8, 0, 0, true));
|
||
}
|
||
|
||
#[test]
|
||
fn wound_weight_decays_smoothstep_to_zero() {
|
||
// Fresh overlay: λ = 0.75 ⇒ w = 0.75/0.25 = 3 against a lone base (others = 1).
|
||
assert!((wound_weight(1.0, 1.0) - 3.0).abs() < 1e-5);
|
||
// Expired: λ = 0 ⇒ the overlay contributes nothing.
|
||
assert_eq!(wound_weight(0.0, 1.0), 0.0);
|
||
// Monotone decay in between (smoothstep, no bounce).
|
||
let mid = wound_weight(0.6, 1.0);
|
||
let late = wound_weight(0.3, 1.0);
|
||
assert!(wound_weight(1.0, 1.0) > mid && mid > late && late > 0.0);
|
||
// Same λ against a heavier subtree (base 1 + a live one-shot overlay 8): the anchor scales.
|
||
assert!((wound_weight(1.0, 9.0) - 27.0).abs() < 1e-4);
|
||
}
|
||
|
||
#[test]
|
||
fn msvc_rand_is_the_crt_lcg() {
|
||
// srand(1)'s canonical first outputs: 41, 18467, 6334 — the exact MSVCRT stream the client's
|
||
// variation roll consumes (wow-re rf36-rand-stub.md).
|
||
let mut state = 1u32;
|
||
assert_eq!(msvc_rand(&mut state), 41);
|
||
assert_eq!(msvc_rand(&mut state), 18467);
|
||
assert_eq!(msvc_rand(&mut state), 6334);
|
||
// Output stays in the client's 0..0x7fff roll domain.
|
||
let mut state = 0xdead_beefu32;
|
||
for _ in 0..100 {
|
||
assert!(msvc_rand(&mut state) <= 0x7fff);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn replay_count_rolls_the_window_multiplier() {
|
||
// (0,0) — the overwhelming majority — always 1.
|
||
assert_eq!(replay_count((0, 0), 0), 1);
|
||
assert_eq!(replay_count((0, 0), 0x7fff), 1);
|
||
// A fixed authored count passes through (clamped ≥ 1).
|
||
assert_eq!(replay_count((3, 3), 0x7fff), 3);
|
||
// A range: R = min + ⌊roll·(max−min)/32768⌋ — max is approached, never exceeded.
|
||
assert_eq!(replay_count((2, 4), 0), 2);
|
||
assert_eq!(replay_count((2, 4), 16384), 3);
|
||
assert_eq!(replay_count((2, 4), 0x7fff), 3); // ⌊32767·2/32768⌋ = 1
|
||
// Malformed (max < min) degrades to min.
|
||
assert_eq!(replay_count((5, 2), 0x7fff), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn defense_anim_matches_the_byte_lut() {
|
||
// Decision 0279: the `0x60ec98` LUT read off WoW.exe — dagger parries 1H (unlike its swing),
|
||
// fist parries UNARMED (unlike its Ready), ranged/none bail; dodge/deflect/block are fixed.
|
||
assert_eq!(defense_anim(2, None), Some(30)); // dodge needs no weapon
|
||
assert_eq!(defense_anim(8, Some((2, 7))), Some(30)); // deflect → Dodge too
|
||
assert_eq!(defense_anim(5, None), Some(24)); // block → ShieldBlock
|
||
assert_eq!(defense_anim(3, Some((2, 7))), Some(21)); // sword 1H
|
||
assert_eq!(defense_anim(3, Some((2, 0xf))), Some(21)); // dagger → Parry1H
|
||
assert_eq!(defense_anim(3, Some((2, 8))), Some(22)); // sword 2H
|
||
assert_eq!(defense_anim(3, Some((2, 0xa))), Some(23)); // staff → Parry2HL
|
||
assert_eq!(defense_anim(3, Some((2, 0xd))), Some(20)); // fist → ParryUnarmed
|
||
assert_eq!(defense_anim(3, Some((2, 2))), None); // bow: the client bails
|
||
assert_eq!(defense_anim(3, Some((15, 0))), None); // non-weapon mainhand: bail
|
||
assert_eq!(defense_anim(3, None), None); // empty hand: bail
|
||
assert_eq!(defense_anim(1, Some((2, 7))), None); // a landed hit defends nothing
|
||
assert_eq!(defense_anim(0, Some((2, 7))), None); // a miss defends nothing
|
||
}
|
||
|
||
#[test]
|
||
fn swing_ids_cover_both_hands() {
|
||
for id in [16, 17, 18, 19, 85, 87, 88, 117] {
|
||
assert!(is_swing_id(id));
|
||
}
|
||
assert!(!is_swing_id(20)); // the defense clips are not swings
|
||
assert!(!is_swing_id(30));
|
||
}
|
||
|
||
#[test]
|
||
fn a_flying_spline_is_fly_before_backward_and_speed() {
|
||
// RF-0057 `0x5fd19c`: the fly branch sits between the swim block and the backward test —
|
||
// a 32 yd/s taxi plays Fly 135, never Sprint 143 (the ≥11 branch) and never WalkBackwards.
|
||
let fly = MovementState {
|
||
flying: true,
|
||
..moving_forward(32.0)
|
||
};
|
||
assert_eq!(gait_candidates(&fly, 2.5, None, None), &[135, 0]);
|
||
let fly_back = MovementState {
|
||
flags: move_flags::BACKWARD,
|
||
..fly
|
||
};
|
||
assert_eq!(gait_candidates(&fly_back, 2.5, None, None), &[135, 0]);
|
||
// A GROUNDED spline ride (charge) keeps the speed cascade.
|
||
assert_eq!(
|
||
gait_candidates(&moving_forward(32.0), 2.5, None, None)[0],
|
||
143
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unify_stamps_flying_from_the_live_spline_on_every_leg() {
|
||
use std::time::{Duration, Instant};
|
||
let spline = |grounded| crate::net::Spline {
|
||
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
|
||
start: Instant::now(),
|
||
duration: Duration::from_secs(10),
|
||
id: 1,
|
||
grounded,
|
||
};
|
||
// Self leg: the controller's stored component says nothing about the ride — the live
|
||
// Spline does (the client reads the active CMovement's spline flags at select time).
|
||
let m = moving_forward(32.0);
|
||
assert!(unify(Some(&m), None, Some(&spline(false)), false).flying);
|
||
assert!(!unify(Some(&m), None, Some(&spline(true)), false).flying);
|
||
assert!(!unify(Some(&m), None, None, false).flying);
|
||
// Spline leg (a remote taxi after the stale-RemoteMotion drop): flying + FORWARD + speed.
|
||
let v = unify(None, None, Some(&spline(false)), false);
|
||
assert!(v.flying && v.flags & move_flags::FORWARD != 0 && v.speed > 0.0);
|
||
}
|
||
|
||
/// The prowl creep outranks the WHOLE speed tail (RF-0057 `0x5fd1d3` precedes `0x5fd202`): a
|
||
/// stealthed unit plays 119 at a dead crawl and at sprint speed alike — never Walk, Run or Sprint.
|
||
#[test]
|
||
fn the_prowl_creeps_at_every_speed() {
|
||
for speed in [0.5, 3.0, 7.0, 32.0] {
|
||
let plain = moving_forward(speed);
|
||
let creeping = MovementState {
|
||
stealthed: true,
|
||
..plain
|
||
};
|
||
assert_eq!(
|
||
gait_candidates(&creeping, 2.5, None, None)[0],
|
||
STEALTH_WALK,
|
||
"stealthed at {speed} yd/s must creep",
|
||
);
|
||
assert_ne!(
|
||
gait_candidates(&plain, 2.5, None, None)[0],
|
||
STEALTH_WALK,
|
||
"unstealthed at {speed} yd/s must fall through to the speed tail",
|
||
);
|
||
}
|
||
// …and it steps down to Walk on a model lacking the clip (AnimationData row 119's own Fallback).
|
||
let creeping = MovementState {
|
||
stealthed: true,
|
||
..moving_forward(7.0)
|
||
};
|
||
assert_eq!(
|
||
gait_candidates(&creeping, 2.5, None, None),
|
||
&[STEALTH_WALK, 4, 0],
|
||
);
|
||
}
|
||
|
||
/// Swim, the flying spline and backward each precede the stealth branch in the byte cascade
|
||
/// (swim → fly → backward → **stealth** → speed tail), so each still wins while stealthed.
|
||
#[test]
|
||
fn swim_fly_and_backpedal_all_outrank_the_prowl() {
|
||
let stealthed = |flags: u32, flying: bool| MovementState {
|
||
speed: 5.0,
|
||
flags,
|
||
stealthed: true,
|
||
flying,
|
||
..Default::default()
|
||
};
|
||
let pick = |s: MovementState| gait_candidates(&s, 2.5, None, None)[0];
|
||
let swim = move_flags::SWIMMING;
|
||
assert_eq!(
|
||
pick(stealthed(swim | move_flags::FORWARD, false)),
|
||
42,
|
||
"a stealthed swimmer strokes",
|
||
);
|
||
assert_eq!(pick(stealthed(swim, false)), 41, "and treads water at rest");
|
||
assert_eq!(
|
||
pick(stealthed(move_flags::FORWARD, true)),
|
||
135,
|
||
"a stealthed flying ride still flies",
|
||
);
|
||
assert_eq!(
|
||
pick(stealthed(move_flags::BACKWARD, false)),
|
||
13,
|
||
"backward is tested BEFORE stealth",
|
||
);
|
||
}
|
||
|
||
/// The prowl idle (`0x5fd830`, last resolver in the chain) sits exactly where plain Stand sat: every
|
||
/// resolver ahead of it — the chair stand-states, the turn shuffle, the combat Ready idle — keeps
|
||
/// winning, and the state-emote idle may still fill the slot ([`is_bare_stand`]).
|
||
#[test]
|
||
fn the_prowl_idle_is_the_lowest_priority_stand() {
|
||
let idle = MovementState {
|
||
stealthed: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(
|
||
gait_candidates(&idle, 2.5, None, None),
|
||
&[STEALTH_STAND, STAND],
|
||
);
|
||
assert!(
|
||
is_bare_stand(gait_candidates(&idle, 2.5, None, None)),
|
||
"the state-emote idle still owns this slot",
|
||
);
|
||
let chair = MovementState {
|
||
stand_state: 4,
|
||
..idle
|
||
};
|
||
assert_eq!(gait_candidates(&chair, 2.5, None, None), &[102, 0]);
|
||
let turning = MovementState {
|
||
flags: move_flags::TURN_LEFT,
|
||
..idle
|
||
};
|
||
assert_eq!(gait_candidates(&turning, 2.5, None, None), &[11, 0]);
|
||
assert_eq!(
|
||
gait_candidates(&idle, 2.5, Some(26), None),
|
||
&[26, 25, 0],
|
||
"an engaged Ready idle outranks the crouch",
|
||
);
|
||
}
|
||
|
||
/// The creep is NOT in the client's locomotion-rate whitelist (`0x5fee80`), so unlike Walk it plays
|
||
/// at 1× however fast the unit is actually travelling.
|
||
#[test]
|
||
fn the_prowl_creep_is_not_rate_scaled() {
|
||
assert_eq!(playback_rate(&clip(STEALTH_WALK, 2.5), 7.0), 1.0);
|
||
assert_ne!(playback_rate(&clip(4, 2.5), 7.0), 1.0);
|
||
}
|