diff --git a/libenforcer-wasm/src/utils.rs b/libenforcer-wasm/src/utils.rs index bbe7173..ab5ea10 100644 --- a/libenforcer-wasm/src/utils.rs +++ b/libenforcer-wasm/src/utils.rs @@ -17,6 +17,22 @@ pub fn is_equal_coord(one: &Coord, other: &Coord) -> bool { pub fn is_box_controller(coordinates: &[Coord]) -> bool { const RIM_COORD_MAX: usize = 432; const THREE_MINUTES: usize = 10800; // frames + const MIN_ANALOG_SMALL_OFF_AXIS_RATIO: f64 = 0.02; + const MIN_ANALOG_SMALL_OFF_AXIS_UNIQUE: usize = 32; + + let (small_off_axis_count, small_off_axis_unique_count) = + count_small_off_axis_coords(coordinates); + let small_off_axis_ratio = if coordinates.is_empty() { + 0.0 + } else { + small_off_axis_count as f64 / coordinates.len() as f64 + }; + + if small_off_axis_ratio >= MIN_ANALOG_SMALL_OFF_AXIS_RATIO + && small_off_axis_unique_count >= MIN_ANALOG_SMALL_OFF_AXIS_UNIQUE + { + return false; + } let rim_count = count_rim_coords(coordinates); let mut rim_proportion = rim_count as f64 / RIM_COORD_MAX as f64; @@ -32,6 +48,26 @@ pub fn is_box_controller(coordinates: &[Coord]) -> bool { rim_proportion < 0.50 } +/// Count coordinates with natural analog off-axis evidence. +/// These are coordinates where both axes are active, but one axis is a small +/// nonzero value. Box controllers should almost never produce many of these. +fn count_small_off_axis_coords(coords: &[Coord]) -> (usize, usize) { + const SMALL_OFF_AXIS_THRESHOLD: f64 = 0.08; + + let mut count = 0; + let mut unique_coords = HashSet::new(); + + for coord in coords { + let min_abs_axis = coord.x.abs().min(coord.y.abs()); + if min_abs_axis > 0.0 && min_abs_axis < SMALL_OFF_AXIS_THRESHOLD { + count += 1; + unique_coords.insert((coord.x.to_bits(), coord.y.to_bits())); + } + } + + (count, unique_coords.len()) +} + /// Count unique coordinates on the rim of the joystick /// A coordinate is on the rim if its distance from center is >= 1.0 fn count_rim_coords(coords: &[Coord]) -> usize { diff --git a/libenforcer-wasm/tests/utils_test.rs b/libenforcer-wasm/tests/utils_test.rs index f48b937..c5faa6e 100644 --- a/libenforcer-wasm/tests/utils_test.rs +++ b/libenforcer-wasm/tests/utils_test.rs @@ -6,7 +6,7 @@ mod common; use common::*; -use libenforcer_wasm::{parser, types, types::Coord, utils}; +use libenforcer_wasm::{checks, parser, types::ControllerType, types::Coord, utils}; use peppi::game::Game; use peppi::io::slippi::de::read as read_slippi; use std::io::Cursor; @@ -211,6 +211,78 @@ fn test_is_box_inputs_orca_dataset_e() { ); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_is_box_inputs_issue15_orca_replays() { + let cases = [ + ( + "legal/analog/orca/issue15_battlefield.slp", + "issue #15 Battlefield", + ), + ( + "legal/analog/orca/issue15_dream_land.slp", + "issue #15 Dream Land", + ), + ]; + + for (path, label) in cases { + let data = read_slp_file(path); + let game = read_slippi(&mut Cursor::new(&data), None).unwrap(); + let player_data = parser::extract_player_data(&game, 0).unwrap(); + + assert_eq!( + utils::is_box_controller(&player_data.main_coords), + false, + "{} Orca P1 should not be detected as box", + label + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_analyze_player_issue15_orca_replays_as_analog() { + let cases = [ + ( + "legal/analog/orca/issue15_battlefield.slp", + "issue #15 Battlefield", + ), + ( + "legal/analog/orca/issue15_dream_land.slp", + "issue #15 Dream Land", + ), + ]; + + for (path, label) in cases { + let data = read_slp_file(path); + let game = read_slippi(&mut Cursor::new(&data), None).unwrap(); + let player_data = parser::extract_player_data(&game, 0).unwrap(); + let analysis = checks::analyze_player(&player_data); + + assert_eq!( + analysis.controller_type, + ControllerType::Analog, + "{} Orca P1 should be analyzed as analog", + label + ); + assert!( + analysis.is_legal, + "{} Orca P1 should pass aggregate legality", + label + ); + assert!( + analysis.sdi.is_none(), + "{} Orca P1 should skip box-only SDI checks", + label + ); + assert!( + analysis.input_fuzzing.is_none(), + "{} Orca P1 should skip box-only input fuzzing checks", + label + ); + } +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn test_is_box_inputs_xbox_controller_a() { diff --git a/test_data/legal/analog/orca/issue15_battlefield.slp b/test_data/legal/analog/orca/issue15_battlefield.slp new file mode 100644 index 0000000..dd6b98c Binary files /dev/null and b/test_data/legal/analog/orca/issue15_battlefield.slp differ diff --git a/test_data/legal/analog/orca/issue15_dream_land.slp b/test_data/legal/analog/orca/issue15_dream_land.slp new file mode 100644 index 0000000..cace56a Binary files /dev/null and b/test_data/legal/analog/orca/issue15_dream_land.slp differ