diff --git a/crates/deadsync-online/src/arrowcloud.rs b/crates/deadsync-online/src/arrowcloud.rs index 3dc9c7deb..a6227e710 100644 --- a/crates/deadsync-online/src/arrowcloud.rs +++ b/crates/deadsync-online/src/arrowcloud.rs @@ -1366,7 +1366,10 @@ mod tests { AccelEffectsMask, AppearanceEffectsMask, Perspective, ScrollOption, TurnOption, VisualEffectsMask, }; - use deadsync_rules::{judgment, scroll::ScrollSpeedSetting, timing::WindowCounts}; + use deadsync_rules::{ + judgment, scroll::ScrollSpeedSetting, + timing::{ScatterFoot, WindowCounts}, + }; use deadsync_score::{ ArrowCloudPaneKind, ArrowCloudServerGrade, ArrowCloudSubmitUiStatus, ArrowCloudUserContext, RejectReason, @@ -1444,25 +1447,28 @@ mod tests { time_sec: 1.0, offset_ms: Some(8.0), direction_code: 1, - is_stream: false, - is_left_foot: false, miss_because_held: false, + row_index: 0, + quantization_idx: 0, + parity_foot: ScatterFoot::Unknown, }, ScatterPoint { time_sec: 1.5, offset_ms: None, direction_code: 2, - is_stream: false, - is_left_foot: false, miss_because_held: false, + row_index: 1, + quantization_idx: 0, + parity_foot: ScatterFoot::Unknown, }, ScatterPoint { time_sec: 3.0, offset_ms: Some(1.0), direction_code: 3, - is_stream: false, - is_left_foot: false, miss_because_held: false, + row_index: 2, + quantization_idx: 0, + parity_foot: ScatterFoot::Unknown, }, ]; diff --git a/crates/deadsync-rules/src/timing.rs b/crates/deadsync-rules/src/timing.rs index 22d2051c3..e1922fbad 100644 --- a/crates/deadsync-rules/src/timing.rs +++ b/crates/deadsync-rules/src/timing.rs @@ -1,6 +1,5 @@ use crate::judgment::{self, JudgeGrade, Judgment, TimingWindow}; use crate::note::Note; -use crate::stream::StreamSegment; use deadsync_core::note::NoteType; use deadsync_core::timing::{beat_to_note_row, note_row_to_beat}; use log::debug; @@ -1408,12 +1407,14 @@ pub fn compute_note_timing_stats(notes: &[Note]) -> TimingStats { /// per-arrow timing pane on the evaluation screen. /// /// `per_column` has one entry per column on the player's pad (e.g. 4 for -/// dance-single). `left_foot` / `right_foot` are computed using the same -/// alternation heuristic as [`build_scatter_points`]: a step on the -/// outermost-left column forces the left foot, a step on the -/// outermost-right column forces the right foot, and anything else flips -/// the foot from the previous row. Chord notes share the row's -/// alternated foot. +/// dance-single). `left_foot` / `right_foot` are taken from real `rssp` foot +/// parity when a per-note parity map is supplied (each arrow, including the two +/// halves of a jump, is attributed to its actual foot). When no parity is +/// available for a note (e.g. non-4/8-panel charts, or rows `rssp` skips), the +/// foot falls back to a simple alternation heuristic: a step on the +/// outermost-left column forces the left foot, a step on the outermost-right +/// column forces the right foot, and anything else flips the foot from the +/// previous row. #[derive(Clone, Debug, Default)] pub struct ArrowTimingStats { pub per_column: Vec, @@ -1482,6 +1483,7 @@ pub fn compute_arrow_timing_stats( notes: &[Note], col_offset: usize, cols_per_player: usize, + foot_by_note: Option<&std::collections::HashMap<(usize, usize), ScatterFoot>>, ) -> ArrowTimingStats { let mut per_column: Vec = vec![StatsAccum::default(); cols_per_player]; let mut left = StatsAccum::default(); @@ -1497,8 +1499,8 @@ pub fn compute_arrow_timing_stats( let row_start = idx; row_judgments.clear(); - // Direction code mirrors `build_scatter_points`: 1 = leftmost column, - // `cols_per_player` = rightmost column, anything else is a chord. + // Direction code: 1 = leftmost column, `cols_per_player` = rightmost + // column, anything else is a chord. let mut direction_code: u32 = 0; while idx < len && notes[idx].row_index == row_index { let note = ¬es[idx]; @@ -1513,9 +1515,8 @@ pub fn compute_arrow_timing_stats( idx += 1; } - // Alternation must mirror `build_scatter_points` exactly, even for - // rows whose final judgment is a Miss, so foot assignments stay in - // sync with the per-arrow scatter plot. + // Fallback alternation runs even for rows whose final judgment is a + // Miss, so foot assignments stay consistent when parity is unavailable. let leftmost = 1u32; let rightmost = cols_per_player as u32; if direction_code == leftmost { @@ -1537,8 +1538,9 @@ pub fn compute_arrow_timing_stats( // Per-column: each judgeable tap note in the row gets attributed // to its own column, but they all share the row's aggregated - // offset, so chord arrows count once per arrow. - let foot_bucket = if foot_left { &mut left } else { &mut right }; + // offset, so chord arrows count once per arrow. Each arrow's foot + // comes from real `rssp` parity when available, else the row's + // alternated foot. for n in ¬es[row_start..idx] { if n.is_fake || !n.can_be_judged @@ -1551,7 +1553,16 @@ pub fn compute_arrow_timing_stats( let col = (code as usize).saturating_sub(1); if col < cols_per_player { per_column[col].add(e); - foot_bucket.add(e); + let use_left = match foot_by_note.and_then(|m| m.get(&(n.row_index, n.column))) { + Some(ScatterFoot::Left) => true, + Some(ScatterFoot::Right) => false, + _ => foot_left, + }; + if use_left { + left.add(e); + } else { + right.add(e); + } } } } @@ -1564,15 +1575,36 @@ pub fn compute_arrow_timing_stats( } } +/// Real left/right foot placement for a scatter row, sourced from `rssp` parity +/// data in the app layer (this crate stays `rssp`-free). `Unknown` means no +/// parity was available (e.g. non-4/8-panel charts) and the foot-parity scatter +/// treats it like a non-single-foot row. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum ScatterFoot { + #[default] + Unknown, + Left, + Right, + /// Both feet used on the same row (a jump); plotted black, like Simply Love. + Both, +} + #[derive(Copy, Clone, Debug)] pub struct ScatterPoint { pub time_sec: f32, pub offset_ms: Option, // None for Miss // Arrow Cloud-style "direction" code: 1..4 for L/D/U/R, other values for jumps/chords. pub direction_code: u8, - pub is_stream: bool, - pub is_left_foot: bool, pub miss_because_held: bool, + /// Note-row index this point came from, so the app layer can join real + /// `rssp` foot-parity data back onto the point. + pub row_index: usize, + /// Quantization bucket of the representative note (0=4th .. 8=192nd), for + /// the by-quantization scatter. + pub quantization_idx: u8, + /// Real foot placement from `rssp` parity, for the by-foot scatter. Defaults + /// to `Unknown` until the app layer fills it in. + pub parity_foot: ScatterFoot, } #[derive(Clone, Debug, Default)] @@ -1601,27 +1633,14 @@ fn local_direction_code(note: &Note, col_offset: usize, cols_per_player: usize) Some(code) } -#[inline(always)] -fn is_stream_beat(beat: f32, stream_segments: &[StreamSegment]) -> bool { - if stream_segments.is_empty() { - return false; - } - let measure = (beat.floor() as i32).div_euclid(4).max(0) as usize; - stream_segments - .iter() - .any(|seg| !seg.is_break && measure >= seg.start && measure < seg.end) -} - #[inline(always)] pub fn build_scatter_points( notes: &[Note], note_time_cache_ns: &[i64], col_offset: usize, cols_per_player: usize, - stream_segments: &[StreamSegment], ) -> Vec { let mut out = Vec::with_capacity(notes.len()); - let mut foot_left = false; let mut row_start = 0usize; while row_start < notes.len() { @@ -1660,14 +1679,6 @@ pub fn build_scatter_points( } } - if direction_code == 1 { - foot_left = true; - } else if direction_code == 4 { - foot_left = false; - } else if direction_code > 0 { - foot_left = !foot_left; - } - let Some(idx) = representative_ix else { row_start = row_end; continue; @@ -1687,9 +1698,10 @@ pub fn build_scatter_points( time_sec: t, offset_ms, direction_code, - is_stream: is_stream_beat(notes[idx].beat, stream_segments), - is_left_foot: foot_left, miss_because_held: judgment.grade == JudgeGrade::Miss && judgment.miss_because_held, + row_index: row, + quantization_idx: notes[idx].quantization_idx, + parity_foot: ScatterFoot::Unknown, }); row_start = row_end; @@ -2062,7 +2074,7 @@ mod tests { test_note(15, 0, JudgeGrade::Fantastic, 4.0), ]; - let stats = compute_arrow_timing_stats(¬es, 0, 4); + let stats = compute_arrow_timing_stats(¬es, 0, 4, None); assert_eq!(stats.per_column.len(), 4); assert_eq!(stats.per_column[0].count, 2); assert!((stats.per_column[0].stats.mean_ms - 0.0).abs() < 0.0001); @@ -2089,7 +2101,7 @@ mod tests { test_note(3, 2, JudgeGrade::Fantastic, 4.0), ]; - let stats = compute_arrow_timing_stats(¬es, 0, 4); + let stats = compute_arrow_timing_stats(¬es, 0, 4, None); // Left foot: row 0 (col 0) + chord row 3 contributes both arrows. assert_eq!(stats.left_foot.count, 3); // Right foot: chord row 1 (both arrows) + row 2 (col 3). @@ -2111,11 +2123,34 @@ mod tests { test_note(2, 2, JudgeGrade::Fantastic, 5.0), // alternates -> left ]; - let stats = compute_arrow_timing_stats(¬es, 0, 4); + let stats = compute_arrow_timing_stats(¬es, 0, 4, None); assert_eq!(stats.left_foot.count, 2); assert_eq!(stats.right_foot.count, 0); } + #[test] + fn arrow_timing_stats_use_parity_map_over_alternation() { + use std::collections::HashMap; + // A jump on columns 1 & 2. The alternation fallback attributes both + // arrows to a single foot; real parity splits them one per foot. + let notes = vec![ + test_note(0, 1, JudgeGrade::Fantastic, 2.0), + test_note(0, 2, JudgeGrade::Fantastic, 2.0), + ]; + + let mut parity: HashMap<(usize, usize), ScatterFoot> = HashMap::new(); + parity.insert((0, 1), ScatterFoot::Left); + parity.insert((0, 2), ScatterFoot::Right); + let stats = compute_arrow_timing_stats(¬es, 0, 4, Some(&parity)); + assert_eq!(stats.left_foot.count, 1); + assert_eq!(stats.right_foot.count, 1); + + // Without parity the same jump lands entirely on one alternated foot. + let alt = compute_arrow_timing_stats(¬es, 0, 4, None); + assert_eq!(alt.left_foot.count + alt.right_foot.count, 2); + assert!(alt.left_foot.count == 0 || alt.right_foot.count == 0); + } + #[test] fn live_timing_stats_keep_recent_64_and_all_samples() { let mut stats = LiveTimingStats::default(); @@ -2200,7 +2235,7 @@ mod tests { ]; let note_time_cache_ns = vec![1_000_000_000, 1_000_000_000]; - let scatter = build_scatter_points(¬es, ¬e_time_cache_ns, 0, 4, &[]); + let scatter = build_scatter_points(¬es, ¬e_time_cache_ns, 0, 4); assert_eq!(scatter.len(), 1); assert_eq!(scatter[0].offset_ms, Some(12.0)); diff --git a/src/app/mod.rs b/src/app/mod.rs index 77f967d80..6827faddb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11399,9 +11399,10 @@ mod tests { time_sec: 12.0, offset_ms: Some(10.0), direction_code: 1, - is_stream: false, - is_left_foot: true, miss_because_held: false, + row_index: 0, + quantization_idx: 0, + parity_foot: timing_rules::ScatterFoot::Unknown, }], scatter_worst_window_ms: 45.0, histogram: timing_rules::HistogramMs { diff --git a/src/game/gameplay.rs b/src/game/gameplay.rs index 7f9a3f9ef..262f5ce88 100644 --- a/src/game/gameplay.rs +++ b/src/game/gameplay.rs @@ -563,7 +563,7 @@ fn build_crossover_rows( notes: &[Note], note_range: (usize, usize), col_start: usize, -) -> (Vec<[u8; LANES]>, Vec) { +) -> (Vec<[u8; LANES]>, Vec, Vec) { use std::collections::BTreeMap; let (start, end) = note_range; let mut rows: BTreeMap = BTreeMap::new(); @@ -596,11 +596,13 @@ fn build_crossover_rows( } let mut row_arrays = Vec::with_capacity(rows.len()); let mut row_to_beat = Vec::with_capacity(rows.len()); - for (_row_index, (arr, beat)) in rows { + let mut row_indices = Vec::with_capacity(rows.len()); + for (row_index, (arr, beat)) in rows { row_arrays.push(arr); row_to_beat.push(beat); + row_indices.push(row_index); } - (row_arrays, row_to_beat) + (row_arrays, row_to_beat, row_indices) } /// Uses the player's base `TimingData` (not rate-scaled) so cue times share the @@ -761,7 +763,8 @@ fn build_crossover_cues_for_player( let rssp_timing = rssp::timing::timing_data_from_segments(0.0, 0.0, &rssp_segments); let annos: Vec = match cols_per_player { 4 => { - let (rows, row_to_beat) = build_crossover_rows::<4>(notes, note_range, col_start); + let (rows, row_to_beat, _row_indices) = + build_crossover_rows::<4>(notes, note_range, col_start); let Some(mut scratch) = rssp::step_parity::timing_rows_scratch::<4>() else { return Vec::new(); }; @@ -773,7 +776,8 @@ fn build_crossover_cues_for_player( ) } 8 => { - let (rows, row_to_beat) = build_crossover_rows::<8>(notes, note_range, col_start); + let (rows, row_to_beat, _row_indices) = + build_crossover_rows::<8>(notes, note_range, col_start); let Some(mut scratch) = rssp::step_parity::timing_rows_scratch::<8>() else { return Vec::new(); }; @@ -800,6 +804,152 @@ fn build_crossover_cues_for_player( ) } +/// Reduces one `rssp` row annotation to a single foot placement for the +/// by-foot evaluation scatter: left if any column uses a left foot, right if any +/// uses a right foot, both when a row uses both feet (a jump), or `None` when no +/// foot steps on the row (so the caller can skip it). +#[inline] +fn parity_foot_from_annotation( + anno: &rssp::RowAnnotation, +) -> Option { + use deadsync_rules::timing::ScatterFoot; + let mut uses_left = false; + let mut uses_right = false; + for &foot in anno.feet() { + match foot { + rssp::Foot::LeftHeel | rssp::Foot::LeftToe => uses_left = true, + rssp::Foot::RightHeel | rssp::Foot::RightToe => uses_right = true, + rssp::Foot::None => {} + } + } + match (uses_left, uses_right) { + (true, true) => Some(ScatterFoot::Both), + (true, false) => Some(ScatterFoot::Left), + (false, true) => Some(ScatterFoot::Right), + (false, false) => None, + } +} + +fn foot_parity_map( + notes: &[Note], + note_range: (usize, usize), + col_start: usize, + rssp_timing: &rssp::timing::TimingData, +) -> std::collections::HashMap { + use std::collections::HashMap; + let (rows, row_to_beat, row_indices) = build_crossover_rows::(notes, note_range, col_start); + let Some(mut scratch) = rssp::step_parity::timing_rows_scratch::() else { + return HashMap::new(); + }; + let annos = rssp::step_parity::annotate_timing_rows::( + &rows, + &row_to_beat, + rssp_timing, + &mut scratch, + ); + let mut map = HashMap::with_capacity(annos.len()); + for (anno, &row_index) in annos.iter().zip(row_indices.iter()) { + if let Some(placement) = parity_foot_from_annotation(anno) { + map.insert(row_index, placement); + } + } + map +} + +/// Per-row left/right/both foot placement from `rssp` parity, keyed by note +/// `row_index`, for the evaluation by-foot scatter. Returns an empty map on +/// non-4/8-panel layouts (the only layouts `rssp` parity models), in which case +/// the scatter falls back to plotting those rows black. +pub fn foot_parity_by_row_for_results( + state: &State, + player: usize, +) -> std::collections::HashMap { + use std::collections::HashMap; + if player >= state.num_players { + return HashMap::new(); + } + let cols_per_player = state.cols_per_player; + let note_range = state.note_ranges[player]; + if note_range.0 >= note_range.1 { + return HashMap::new(); + } + let col_start = player.saturating_mul(cols_per_player); + let timing_segments = &state.gameplay_charts[player].timing_segments; + let rssp_segments = rssp_timing_segments_from_deadsync(timing_segments); + let rssp_timing = rssp::timing::timing_data_from_segments(0.0, 0.0, &rssp_segments); + match cols_per_player { + 4 => foot_parity_map::<4>(&state.notes, note_range, col_start, &rssp_timing), + 8 => foot_parity_map::<8>(&state.notes, note_range, col_start, &rssp_timing), + _ => HashMap::new(), + } +} + +/// Per-arrow left/right foot placement from `rssp` parity, keyed by +/// `(row_index, absolute column)`, for the per-arrow timing-stats pane. Splits +/// jumps correctly (each arrow gets its own foot). Returns an empty map on +/// non-4/8-panel layouts, in which case the timing stats fall back to the +/// alternation heuristic. +fn foot_parity_by_note_map( + notes: &[Note], + note_range: (usize, usize), + col_start: usize, + rssp_timing: &rssp::timing::TimingData, +) -> std::collections::HashMap<(usize, usize), deadsync_rules::timing::ScatterFoot> { + use deadsync_rules::timing::ScatterFoot; + use std::collections::HashMap; + let (rows, row_to_beat, row_indices) = + build_crossover_rows::(notes, note_range, col_start); + let Some(mut scratch) = rssp::step_parity::timing_rows_scratch::() else { + return HashMap::new(); + }; + let annos = rssp::step_parity::annotate_timing_rows::( + &rows, + &row_to_beat, + rssp_timing, + &mut scratch, + ); + let mut map = HashMap::new(); + for (anno, &row_index) in annos.iter().zip(row_indices.iter()) { + for local in 0..LANES { + let foot = match anno.foot(local) { + rssp::Foot::LeftHeel | rssp::Foot::LeftToe => ScatterFoot::Left, + rssp::Foot::RightHeel | rssp::Foot::RightToe => ScatterFoot::Right, + rssp::Foot::None => continue, + }; + map.insert((row_index, col_start + local), foot); + } + } + map +} + +/// Per-arrow left/right foot placement from `rssp` parity for the per-arrow +/// timing-stats pane, keyed by `(row_index, absolute column)`. Returns an empty +/// map on non-4/8-panel layouts (the only layouts `rssp` parity models), so the +/// timing stats fall back to the alternation heuristic. +pub fn foot_parity_by_note_for_results( + state: &State, + player: usize, +) -> std::collections::HashMap<(usize, usize), deadsync_rules::timing::ScatterFoot> { + use std::collections::HashMap; + if player >= state.num_players { + return HashMap::new(); + } + let cols_per_player = state.cols_per_player; + let note_range = state.note_ranges[player]; + if note_range.0 >= note_range.1 { + return HashMap::new(); + } + let col_start = player.saturating_mul(cols_per_player); + let timing_segments = &state.gameplay_charts[player].timing_segments; + let rssp_segments = rssp_timing_segments_from_deadsync(timing_segments); + let rssp_timing = rssp::timing::timing_data_from_segments(0.0, 0.0, &rssp_segments); + match cols_per_player { + 4 => foot_parity_by_note_map::<4>(&state.notes, note_range, col_start, &rssp_timing), + 8 => foot_parity_by_note_map::<8>(&state.notes, note_range, col_start, &rssp_timing), + _ => HashMap::new(), + } +} + #[inline(always)] fn compute_column_scroll_dirs( scroll_option: profile_data::ScrollOption, diff --git a/src/game/scores/arrowcloud.rs b/src/game/scores/arrowcloud.rs index eb11e355f..d6d7b81dc 100644 --- a/src/game/scores/arrowcloud.rs +++ b/src/game/scores/arrowcloud.rs @@ -262,14 +262,7 @@ fn arrowcloud_timing_data( let notes = &gs.notes[start..end]; let note_times = &gs.note_time_cache_ns[start..end]; let col_offset = player_idx.saturating_mul(gs.cols_per_player); - let stream_segments = gameplay::stream_segments_for_results(gs, player_idx); - let scatter = timing::build_scatter_points( - notes, - note_times, - col_offset, - gs.cols_per_player, - &stream_segments, - ); + let scatter = timing::build_scatter_points(notes, note_times, col_offset, gs.cols_per_player); let fail_time_s = fail_time_ns.map(gameplay::song_time_ns_to_seconds); arrowcloud_api::timing_data_from_scatter(&scatter, fail_time_s) } @@ -885,7 +878,7 @@ mod tests { ArrowCloudJudgmentCounts, ArrowCloudModifiers, ArrowCloudNpsInfo, ArrowCloudSpeed, ArrowCloudTimingOffset, }; - use deadsync_rules::timing::ScatterPoint; + use deadsync_rules::timing::{ScatterFoot, ScatterPoint}; use serde_json::{Value, json}; fn sample_scatter(time_sec: f32, offset_ms: Option) -> ScatterPoint { @@ -893,9 +886,10 @@ mod tests { time_sec, offset_ms, direction_code: 1, - is_stream: false, - is_left_foot: false, miss_because_held: false, + row_index: 0, + quantization_idx: 0, + parity_foot: ScatterFoot::Unknown, } } diff --git a/src/screens/components/evaluation/eval_graphs.rs b/src/screens/components/evaluation/eval_graphs.rs index 76f7f3148..eda4a6756 100644 --- a/src/screens/components/evaluation/eval_graphs.rs +++ b/src/screens/components/evaluation/eval_graphs.rs @@ -1,6 +1,6 @@ use deadsync_present::color; use deadsync_render::MeshVertex; -use deadsync_rules::timing::{self, HistogramMs, ScatterPoint}; +use deadsync_rules::timing::{self, HistogramMs, ScatterFoot, ScatterPoint}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TimingHistogramScale { @@ -15,7 +15,10 @@ pub enum ScatterPlotScale { Ex, HardEx, Arrow, - Foot, + /// Color points by note quantization (Simply Love's ScatterPlotQuantization). + Quant, + /// Color points by real `rssp` foot parity (Simply Love's ScatterPlotFoot). + FootParity, } const HIST_BIN_MS: f32 = 1.0; @@ -107,15 +110,32 @@ fn color_for_arrow(direction_code: u8) -> [f32; 4] { } } +/// Simply Love `ScatterPlotQuantization` palette, keyed by deadsync's +/// quantization index (0=4th .. 8=192nd). 64th and 192nd share teal, matching +/// SL grouping the finest quantizations together. Unknown indices plot black. #[inline(always)] -fn color_for_foot(is_stream: bool, is_left_foot: bool) -> [f32; 4] { - if !is_stream { - return [0.0, 0.0, 0.0, 1.0]; +fn color_for_quant(quantization_idx: u8) -> [f32; 4] { + match quantization_idx { + 0 => [232.0 / 255.0, 0.0, 0.0, 1.0], // 4th - red #e80000 + 1 => [0.0, 102.0 / 255.0, 1.0, 1.0], // 8th - blue #0066ff + 2 => [149.0 / 255.0, 0.0, 1.0, 1.0], // 12th - purple #9500ff + 3 => [0.0, 1.0, 0.0, 1.0], // 16th - green #00ff00 + 4 => [1.0, 102.0 / 255.0, 153.0 / 255.0, 1.0], // 24th - pink #ff6699 + 5 => [1.0, 1.0, 0.0, 1.0], // 32nd - yellow #ffff00 + 6 => [1.0, 205.0 / 255.0, 224.0 / 255.0, 1.0], // 48th - l.pink #ffcde0 + 7 | 8 => [0.0, 232.0 / 255.0, 229.0 / 255.0, 1.0], // 64th/192nd - teal #00e8e5 + _ => [0.0, 0.0, 0.0, 1.0], } - if is_left_foot { - [1.0, 0.0, 0.0, 1.0] - } else { - [0.0, 0.0, 1.0, 1.0] +} + +/// Simply Love `ScatterPlotFoot` coloring: left foot red, right foot blue, and +/// jumps (both feet) or unknown rows black. +#[inline(always)] +fn color_for_foot_parity(parity_foot: ScatterFoot) -> [f32; 4] { + match parity_foot { + ScatterFoot::Left => [1.0, 0.0, 0.0, 1.0], + ScatterFoot::Right => [0.0, 0.0, 1.0, 1.0], + ScatterFoot::Both | ScatterFoot::Unknown => [0.0, 0.0, 0.0, 1.0], } } @@ -137,7 +157,8 @@ fn color_for_scatter( color_for_abs_ms(abs_ms, timing_windows_ms, TimingHistogramScale::HardEx) } ScatterPlotScale::Arrow => color_for_arrow(sp.direction_code), - ScatterPlotScale::Foot => color_for_foot(sp.is_stream, sp.is_left_foot), + ScatterPlotScale::Quant => color_for_quant(sp.quantization_idx), + ScatterPlotScale::FootParity => color_for_foot_parity(sp.parity_foot), } } @@ -148,7 +169,8 @@ fn miss_color_for_scatter(sp: &ScatterPoint, scale: ScatterPlotScale) -> [f32; 4 [1.0, 0.0, 0.0, 1.0] } ScatterPlotScale::Arrow => color_for_arrow(sp.direction_code), - ScatterPlotScale::Foot => color_for_foot(sp.is_stream, sp.is_left_foot), + ScatterPlotScale::Quant => color_for_quant(sp.quantization_idx), + ScatterPlotScale::FootParity => color_for_foot_parity(sp.parity_foot), } } @@ -156,7 +178,9 @@ fn miss_color_for_scatter(sp: &ScatterPoint, scale: ScatterPlotScale) -> [f32; 4 fn scatter_hit_alpha(scale: ScatterPlotScale) -> f32 { match scale { ScatterPlotScale::Itg | ScatterPlotScale::Ex | ScatterPlotScale::HardEx => 1.0, - ScatterPlotScale::Arrow | ScatterPlotScale::Foot => 0.666, + ScatterPlotScale::Arrow + | ScatterPlotScale::Quant + | ScatterPlotScale::FootParity => 0.666, } } @@ -246,7 +270,9 @@ pub fn build_scatter_background_mesh( (timing_windows_ms[3], color::JUDGMENT_RGBA[3]), (timing_windows_ms[4], color::JUDGMENT_RGBA[4]), ], - ScatterPlotScale::Arrow | ScatterPlotScale::Foot => return Vec::new(), + ScatterPlotScale::Arrow + | ScatterPlotScale::Quant + | ScatterPlotScale::FootParity => return Vec::new(), }; // Matches Simply Love's `diffusealpha(0.1)` on its judgment-region quads. @@ -494,9 +520,10 @@ mod tests { time_sec: 1.0, offset_ms: Some(offset_ms), direction_code: 1, - is_stream: true, - is_left_foot: true, miss_because_held: false, + row_index: 0, + quantization_idx: 0, + parity_foot: ScatterFoot::Unknown, } } diff --git a/src/screens/evaluation.rs b/src/screens/evaluation.rs index 2b8998caf..63e9b47a7 100644 --- a/src/screens/evaluation.rs +++ b/src/screens/evaluation.rs @@ -963,7 +963,7 @@ fn build_eval_scatter_bg_mesh( EvalGraphPane::Itg => ScatterPlotScale::Itg, EvalGraphPane::Ex => ScatterPlotScale::Ex, EvalGraphPane::HardEx => ScatterPlotScale::HardEx, - EvalGraphPane::Arrow | EvalGraphPane::Foot => return None, + EvalGraphPane::Arrow | EvalGraphPane::Quant | EvalGraphPane::FootParity => return None, }; const GRAPH_H: f32 = 64.0; let verts = @@ -2039,7 +2039,8 @@ enum EvalGraphPane { Ex, HardEx, Arrow, - Foot, + Quant, + FootParity, } #[inline(always)] @@ -2056,7 +2057,24 @@ const fn eval_graph_default_for(show_fa_plus_pane: bool, show_hard_ex: bool) -> #[inline(always)] fn eval_graph_cycle(show_fa_plus_pane: bool, show_hard_ex: bool) -> Vec { let scoring = eval_graph_default_for(show_fa_plus_pane, show_hard_ex); - vec![scoring, EvalGraphPane::Arrow, EvalGraphPane::Foot] + vec![ + scoring, + EvalGraphPane::Arrow, + EvalGraphPane::Quant, + EvalGraphPane::FootParity, + ] +} + +/// Short label describing what each scatter pane's colors encode, shown in the +/// bottom-left corner of the graph. +#[inline(always)] +fn eval_graph_pane_label(pane: EvalGraphPane) -> &'static str { + match pane { + EvalGraphPane::Itg | EvalGraphPane::Ex | EvalGraphPane::HardEx => "Judgment", + EvalGraphPane::Arrow => "Column", + EvalGraphPane::Quant => "Quantization", + EvalGraphPane::FootParity => "Foot", + } } #[inline(always)] @@ -2127,7 +2145,8 @@ pub struct State { pub scatter_bg_mesh_ex: [Option>; MAX_PLAYERS], pub scatter_bg_mesh_hard_ex: [Option>; MAX_PLAYERS], pub scatter_mesh_arrow: [Option>; MAX_PLAYERS], - pub scatter_mesh_foot: [Option>; MAX_PLAYERS], + pub scatter_mesh_quant: [Option>; MAX_PLAYERS], + pub scatter_mesh_foot_parity: [Option>; MAX_PLAYERS], pub density_graph_texture_key: String, pub return_to_course: bool, pub auto_advance_seconds: Option, @@ -2181,7 +2200,8 @@ impl Clone for State { scatter_bg_mesh_ex: self.scatter_bg_mesh_ex.clone(), scatter_bg_mesh_hard_ex: self.scatter_bg_mesh_hard_ex.clone(), scatter_mesh_arrow: self.scatter_mesh_arrow.clone(), - scatter_mesh_foot: self.scatter_mesh_foot.clone(), + scatter_mesh_quant: self.scatter_mesh_quant.clone(), + scatter_mesh_foot_parity: self.scatter_mesh_foot_parity.clone(), density_graph_texture_key: self.density_graph_texture_key.clone(), return_to_course: self.return_to_course, auto_advance_seconds: self.auto_advance_seconds, @@ -2236,7 +2256,9 @@ pub fn init(gameplay_results: Option) -> State { std::array::from_fn(|_| None); let mut scatter_mesh_arrow: [Option>; MAX_PLAYERS] = std::array::from_fn(|_| None); - let mut scatter_mesh_foot: [Option>; MAX_PLAYERS] = + let mut scatter_mesh_quant: [Option>; MAX_PLAYERS] = + std::array::from_fn(|_| None); + let mut scatter_mesh_foot_parity: [Option>; MAX_PLAYERS] = std::array::from_fn(|_| None); let mut active_pane: [EvalPane; MAX_PLAYERS] = [EvalPane::Standard; MAX_PLAYERS]; let mut active_graph: [EvalGraphPane; MAX_PLAYERS] = [EvalGraphPane::Itg; MAX_PLAYERS]; @@ -2269,21 +2291,37 @@ pub fn init(gameplay_results: Option) -> State { let p = &gs.players[player_idx]; let prof = &gs.player_profiles[player_idx]; let col_offset = player_idx.saturating_mul(cols_per_player); - let stream_segments = - crate::game::gameplay::stream_segments_for_results(&gs, player_idx); - // Compute timing statistics across all non-miss tap judgments + // Compute timing statistics across all non-miss tap judgments. + // Real rssp foot parity drives the per-foot breakdown (empty on + // non-4/8-panel charts, where it falls back to alternation). let stats = timing_stats::compute_note_timing_stats(notes); - let arrow_timing = - timing_stats::compute_arrow_timing_stats(notes, col_offset, cols_per_player); + let foot_by_note = + crate::game::gameplay::foot_parity_by_note_for_results(&gs, player_idx); + let arrow_timing = timing_stats::compute_arrow_timing_stats( + notes, + col_offset, + cols_per_player, + (!foot_by_note.is_empty()).then_some(&foot_by_note), + ); // Prepare scatter points and histogram bins - let scatter = timing_stats::build_scatter_points( + let mut scatter = timing_stats::build_scatter_points( notes, note_times, col_offset, cols_per_player, - &stream_segments, ); + // Join real rssp foot-parity onto each scatter row for the by-foot + // scatter pane. Empty on non-4/8-panel charts, leaving rows Unknown. + let foot_parity = + crate::game::gameplay::foot_parity_by_row_for_results(&gs, player_idx); + if !foot_parity.is_empty() { + for sp in &mut scatter { + if let Some(&placement) = foot_parity.get(&sp.row_index) { + sp.parity_foot = placement; + } + } + } let histogram = timing_stats::build_histogram_ms(notes); let scatter_worst_window_ms = { let tw = timing_stats::effective_windows_ms(); @@ -2649,7 +2687,21 @@ pub fn init(gameplay_results: Option) -> State { (!verts.is_empty()).then(|| Arc::from(verts.into_boxed_slice())) }; - scatter_mesh_foot[player_idx] = { + scatter_mesh_quant[player_idx] = { + const GRAPH_H: f32 = 64.0; + let verts = eval_graphs::build_scatter_mesh( + &si.scatter, + si.graph_first_second, + si.graph_last_second, + graph_width, + GRAPH_H, + si.scatter_worst_window_ms, + eval_graphs::ScatterPlotScale::Quant, + ); + (!verts.is_empty()).then(|| Arc::from(verts.into_boxed_slice())) + }; + + scatter_mesh_foot_parity[player_idx] = { const GRAPH_H: f32 = 64.0; let verts = eval_graphs::build_scatter_mesh( &si.scatter, @@ -2658,7 +2710,7 @@ pub fn init(gameplay_results: Option) -> State { graph_width, GRAPH_H, si.scatter_worst_window_ms, - eval_graphs::ScatterPlotScale::Foot, + eval_graphs::ScatterPlotScale::FootParity, ); (!verts.is_empty()).then(|| Arc::from(verts.into_boxed_slice())) }; @@ -2780,7 +2832,8 @@ pub fn init(gameplay_results: Option) -> State { scatter_bg_mesh_ex, scatter_bg_mesh_hard_ex, scatter_mesh_arrow, - scatter_mesh_foot, + scatter_mesh_quant, + scatter_mesh_foot_parity, density_graph_texture_key: "__white".to_string(), return_to_course: false, auto_advance_seconds: None, @@ -2894,10 +2947,15 @@ pub fn init_from_score_info( let si = score_info.get(player_idx).and_then(|s| s.as_ref())?; build_eval_scatter_mesh(si, graph_width, eval_graphs::ScatterPlotScale::Arrow) }); - let scatter_mesh_foot: [Option>; MAX_PLAYERS] = + let scatter_mesh_quant: [Option>; MAX_PLAYERS] = + std::array::from_fn(|player_idx| { + let si = score_info.get(player_idx).and_then(|s| s.as_ref())?; + build_eval_scatter_mesh(si, graph_width, eval_graphs::ScatterPlotScale::Quant) + }); + let scatter_mesh_foot_parity: [Option>; MAX_PLAYERS] = std::array::from_fn(|player_idx| { let si = score_info.get(player_idx).and_then(|s| s.as_ref())?; - build_eval_scatter_mesh(si, graph_width, eval_graphs::ScatterPlotScale::Foot) + build_eval_scatter_mesh(si, graph_width, eval_graphs::ScatterPlotScale::FootParity) }); let timing_hist_mesh: [Option>; MAX_PLAYERS] = std::array::from_fn(|player_idx| { @@ -2935,7 +2993,8 @@ pub fn init_from_score_info( scatter_bg_mesh_ex, scatter_bg_mesh_hard_ex, scatter_mesh_arrow, - scatter_mesh_foot, + scatter_mesh_quant, + scatter_mesh_foot_parity, density_graph_texture_key: "__white".to_string(), return_to_course: false, auto_advance_seconds: None, @@ -4972,19 +5031,24 @@ pub fn push_actors(actors: &mut Vec, state: &State, asset_manager: &Asset EvalGraphPane::Ex => state.scatter_mesh_ex[player_idx].as_ref(), EvalGraphPane::HardEx => state.scatter_mesh_hard_ex[player_idx].as_ref(), EvalGraphPane::Arrow => state.scatter_mesh_arrow[player_idx].as_ref(), - EvalGraphPane::Foot => state.scatter_mesh_foot[player_idx].as_ref(), + EvalGraphPane::Quant => state.scatter_mesh_quant[player_idx].as_ref(), + EvalGraphPane::FootParity => { + state.scatter_mesh_foot_parity[player_idx].as_ref() + } }; let scatter_bg_mesh = if shade { match graph_mode { EvalGraphPane::Itg => state.scatter_bg_mesh_itg[player_idx].as_ref(), EvalGraphPane::Ex => state.scatter_bg_mesh_ex[player_idx].as_ref(), EvalGraphPane::HardEx => state.scatter_bg_mesh_hard_ex[player_idx].as_ref(), - EvalGraphPane::Arrow | EvalGraphPane::Foot => None, + EvalGraphPane::Arrow + | EvalGraphPane::Quant + | EvalGraphPane::FootParity => None, } } else { None }; - let show_feet_overlay = graph_mode == EvalGraphPane::Foot; + let show_feet_overlay = graph_mode == EvalGraphPane::FootParity; let graph_children_vec: Vec = vec![ act!(quad: @@ -5090,6 +5154,15 @@ pub fn push_actors(actors: &mut Vec, state: &State, asset_manager: &Asset act!(sprite("__white"): visible(false)) } }, + act!(text: + font("miso"): + settext(eval_graph_pane_label(graph_mode).to_string()): + align(0.0, 1.0): + xy(3.0, graph_height - 2.0): + zoom(0.5): + diffuse(1.0, 1.0, 1.0, 0.6): + z(6) + ), { // Reserve the worst-case child count (≤2 quads per life-history // change point) so the segment quads never trigger reallocations. diff --git a/src/test_support/evaluation_bench.rs b/src/test_support/evaluation_bench.rs index b834b0b03..a5d37b8ea 100644 --- a/src/test_support/evaluation_bench.rs +++ b/src/test_support/evaluation_bench.rs @@ -5,7 +5,7 @@ use crate::test_support::{compose_scenarios, pane_stats_bench}; use deadsync_core::input::MAX_PLAYERS; use deadsync_present::actors::Actor; use deadsync_profile as profile_data; -use deadsync_rules::timing::{HistogramMs, ScatterPoint}; +use deadsync_rules::timing::{HistogramMs, ScatterFoot, ScatterPoint}; use deadsync_score::LeaderboardEntry; pub const SCENARIO_NAME: &str = "evaluation"; @@ -109,9 +109,10 @@ fn bench_scatter() -> Vec { time_sec: t * 128.0, offset_ms: if is_miss { None } else { Some(offset) }, direction_code: (i % 4 + 1) as u8, - is_stream: i % 3 == 0, - is_left_foot: i % 2 == 0, miss_because_held: false, + row_index: i, + quantization_idx: 0, + parity_foot: ScatterFoot::Unknown, } }) .collect()