diff --git a/libenforcer-wasm/src/utils.rs b/libenforcer-wasm/src/utils.rs index ab5ea10..4429914 100644 --- a/libenforcer-wasm/src/utils.rs +++ b/libenforcer-wasm/src/utils.rs @@ -19,6 +19,8 @@ pub fn is_box_controller(coordinates: &[Coord]) -> bool { 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; + const MIN_ANALOG_CARDINAL_UNIQUE: usize = 100; + const MIN_ANALOG_RIM_UNIQUE: usize = 40; let (small_off_axis_count, small_off_axis_unique_count) = count_small_off_axis_coords(coordinates); @@ -35,6 +37,15 @@ pub fn is_box_controller(coordinates: &[Coord]) -> bool { } let rim_count = count_rim_coords(coordinates); + + if count_strict_rim_coords(coordinates) >= MIN_ANALOG_RIM_UNIQUE { + let (_, cardinal_analog_unique_count) = count_cardinal_analog_coords(coordinates); + + if cardinal_analog_unique_count >= MIN_ANALOG_CARDINAL_UNIQUE { + return false; + } + } + let mut rim_proportion = rim_count as f64 / RIM_COORD_MAX as f64; // Boost proportion for shorter games to avoid false positives @@ -68,6 +79,43 @@ fn count_small_off_axis_coords(coords: &[Coord]) -> (usize, usize) { (count, unique_coords.len()) } +/// Count unique coordinates that are actually on the rim, without fuzz tolerance. +/// The box fallback uses tolerant rim detection; this stricter version avoids +/// treating normal one-step fuzz around full cardinals as analog rim coverage. +fn count_strict_rim_coords(coords: &[Coord]) -> usize { + let mut rim_coords = HashSet::new(); + + for coord in coords { + let distance = (coord.x.powi(2) + coord.y.powi(2)).sqrt(); + + if distance >= 1.0 { + rim_coords.insert((coord.x.to_bits(), coord.y.to_bits())); + } + } + + rim_coords.len() +} + +/// Count axis-aligned coordinates with analog magnitudes below full cardinal. +/// This catches analog-button traces that have little off-axis noise, while the +/// rim-coordinate requirement keeps ordinary fuzzed box inputs classified as box. +fn count_cardinal_analog_coords(coords: &[Coord]) -> (usize, usize) { + 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()); + let max_abs_axis = coord.x.abs().max(coord.y.abs()); + + if min_abs_axis == 0.0 && max_abs_axis > 0.0 && max_abs_axis < 1.0 { + 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 c5faa6e..4781499 100644 --- a/libenforcer-wasm/tests/utils_test.rs +++ b/libenforcer-wasm/tests/utils_test.rs @@ -283,6 +283,107 @@ fn test_analyze_player_issue15_orca_replays_as_analog() { } } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_is_box_inputs_additional_orca_false_positive_replays() { + let cases = [ + ( + "legal/analog/orca/travel_time_20251228_230022.slp", + 0, + "travel time 20251228 230022", + ), + ( + "legal/analog/orca/travel_time_20251228_230302.slp", + 0, + "travel time 20251228 230302", + ), + ( + "legal/analog/orca/travel_time_20260227_104159.slp", + 0, + "travel time 20260227 104159", + ), + ( + "legal/analog/orca/illegal_sdi_20260418_224946.slp", + 1, + "illegal SDI 20260418 224946", + ), + ]; + + for (path, player_index, 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, player_index).unwrap(); + + assert_eq!( + utils::is_box_controller(&player_data.main_coords), + false, + "{} Orca player should not be detected as box", + label + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_analyze_player_additional_orca_false_positive_replays_as_analog() { + let cases = [ + ( + "legal/analog/orca/travel_time_20251228_230022.slp", + 0, + "travel time 20251228 230022", + ), + ( + "legal/analog/orca/travel_time_20251228_230302.slp", + 0, + "travel time 20251228 230302", + ), + ( + "legal/analog/orca/travel_time_20260227_104159.slp", + 0, + "travel time 20260227 104159", + ), + ( + "legal/analog/orca/illegal_sdi_20260418_224946.slp", + 1, + "illegal SDI 20260418 224946", + ), + ]; + + for (path, player_index, 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, player_index).unwrap(); + let analysis = checks::analyze_player(&player_data); + + assert_eq!( + analysis.controller_type, + ControllerType::Analog, + "{} Orca player should be analyzed as analog", + label + ); + assert!( + analysis.is_legal, + "{} Orca player should pass aggregate legality", + label + ); + assert!( + analysis.travel_time.is_none(), + "{} Orca player should skip box-only travel time checks", + label + ); + assert!( + analysis.sdi.is_none(), + "{} Orca player should skip box-only SDI checks", + label + ); + assert!( + analysis.input_fuzzing.is_none(), + "{} Orca player 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/illegal_sdi_20260418_224946.slp b/test_data/legal/analog/orca/illegal_sdi_20260418_224946.slp new file mode 100644 index 0000000..2806daa Binary files /dev/null and b/test_data/legal/analog/orca/illegal_sdi_20260418_224946.slp differ diff --git a/test_data/legal/analog/orca/travel_time_20251228_230022.slp b/test_data/legal/analog/orca/travel_time_20251228_230022.slp new file mode 100644 index 0000000..97462b7 Binary files /dev/null and b/test_data/legal/analog/orca/travel_time_20251228_230022.slp differ diff --git a/test_data/legal/analog/orca/travel_time_20251228_230302.slp b/test_data/legal/analog/orca/travel_time_20251228_230302.slp new file mode 100644 index 0000000..77cfef6 Binary files /dev/null and b/test_data/legal/analog/orca/travel_time_20251228_230302.slp differ diff --git a/test_data/legal/analog/orca/travel_time_20260227_104159.slp b/test_data/legal/analog/orca/travel_time_20260227_104159.slp new file mode 100644 index 0000000..2132c61 Binary files /dev/null and b/test_data/legal/analog/orca/travel_time_20260227_104159.slp differ