Skip to main content

vimp_engine_core/client/
divergence.rs

1//! Детектор рассинхрона предикта (`plan/ai-debug/stage_5.md`): сравнение
2//! предсказанного состояния своего актора с авторитетным player-блоком
3//! кадра — покомпонентно, с порогами из конфига.
4//!
5//! Существенно: предиктор игры реконсилится **по времени**, а не по `seq`
6//! (история ввода переигрывается от момента авторитетного состояния), поэтому
7//! запись несёт `serverTime`/`offset`/`localNow` и окно переигранной истории
8//! ввода, а `inputSeq` — только справочно, как подтверждённый хостом номер.
9//! Сравнение «по тому же seq» здесь ввело бы в заблуждение.
10//!
11//! Уровень 0 (`Source::Camera`) не требует от плагина ничего: сравнивается
12//! камера predicted-оверлея с x/y авторитетного состояния. Уровень 1
13//! (`Source::State`) — если игра реализовала `GameClientDef::predicted_state`.
14
15use std::collections::VecDeque;
16
17use serde_json::{Value, json};
18
19use crate::config::{DivergenceConfig, PLAYER_STATE_LEN};
20
21/// Откуда взято предсказанное состояние.
22#[derive(Clone, Copy, PartialEq, Eq)]
23pub enum Source {
24    /// Камера predicted-оверлея (x/y) — уровень 0.
25    Camera,
26    /// `GameClientDef::predicted_state` — уровень 1.
27    State,
28}
29
30impl Source {
31    fn as_str(self) -> &'static str {
32        match self {
33            Source::Camera => "camera",
34            Source::State => "state",
35        }
36    }
37}
38
39/// Одно сравнение: момент прихода кадра со своим player-блоком.
40pub struct Observation<'a> {
41    pub source: Source,
42    /// Предсказанное состояние (2 компонента для `Camera`, `PLAYER_STATE_LEN`
43    /// для `State`) — снимается ДО `on_server_state`.
44    pub predicted: &'a [f32],
45    pub authoritative: &'a [f32; PLAYER_STATE_LEN],
46    pub server_time: f64,
47    pub local_now: f64,
48    pub offset: f64,
49    pub input_seq: u32,
50    /// Окно локального времени истории ввода, переигранное последним
51    /// реконсилем: (начало, конец, число вводов).
52    pub replayed: Option<(f64, f64, usize)>,
53}
54
55pub struct DivergenceTracker {
56    cfg: DivergenceConfig,
57    records: VecDeque<Value>,
58    samples: u64,
59    violations: u64,
60    dropped: u64,
61    max_delta: [f32; PLAYER_STATE_LEN],
62}
63
64impl DivergenceTracker {
65    pub fn new(cfg: DivergenceConfig) -> Self {
66        Self {
67            cfg,
68            records: VecDeque::new(),
69            samples: 0,
70            violations: 0,
71            dropped: 0,
72            max_delta: [0.0; PLAYER_STATE_LEN],
73        }
74    }
75
76    /// Сравнивает предсказанное с авторитетным; запись кладётся в кольцевой
77    /// буфер, только если хотя бы один компонент вышел за свой порог —
78    /// иначе отчёт утонул бы в шуме здоровых кадров.
79    pub fn observe(&mut self, obs: Observation) {
80        self.samples += 1;
81
82        let width = obs.predicted.len().min(PLAYER_STATE_LEN);
83        let mut deltas = Vec::with_capacity(width);
84        let mut exceeded = Vec::new();
85
86        for index in 0..width {
87            let delta = obs.predicted[index] - obs.authoritative[index];
88
89            deltas.push(delta);
90
91            if delta.abs() > self.max_delta[index] {
92                self.max_delta[index] = delta.abs();
93            }
94
95            if delta.abs() > self.cfg.threshold(index) {
96                exceeded.push(index);
97            }
98        }
99
100        if exceeded.is_empty() {
101            return;
102        }
103
104        self.violations += 1;
105
106        if self.records.len() >= self.cfg.capacity {
107            self.records.pop_front();
108            self.dropped += 1;
109        }
110
111        let thresholds: Vec<Value> = (0..width)
112            .map(|index| json!(round4(self.cfg.threshold(index))))
113            .collect();
114
115        self.records.push_back(json!({
116            "source": obs.source.as_str(),
117            "serverTime": obs.server_time,
118            "localNow": obs.local_now,
119            "offset": obs.offset,
120            "inputSeq": obs.input_seq,
121            "replayed": obs.replayed.map(|(from, to, count)| json!({
122                "from": from,
123                "to": to,
124                "count": count,
125            })),
126            "predicted": floats(&obs.predicted[..width]),
127            "authoritative": floats(&obs.authoritative[..width]),
128            "delta": floats(&deltas),
129            "exceeded": exceeded,
130            "thresholds": thresholds,
131        }));
132    }
133
134    /// Вычерпывает записи; агрегаты (`samples`/`violations`/`dropped`/
135    /// `maxDelta`) — накопительные за весь прогон, они и есть ответ на «дрейф
136    /// был вообще?» после того, как записи уже прочитаны.
137    pub fn take_json(&mut self) -> String {
138        let records: Vec<Value> = self.records.drain(..).collect();
139
140        json!({
141            "samples": self.samples,
142            "violations": self.violations,
143            "dropped": self.dropped,
144            "maxDelta": floats(&self.max_delta),
145            "records": records,
146        })
147        .to_string()
148    }
149}
150
151fn floats(values: &[f32]) -> Vec<Value> {
152    values.iter().map(|v| json!(round4(*v))).collect()
153}
154
155// f32 → JSON без хвоста двоичного представления (10.100000381469727)
156fn round4(value: f32) -> f64 {
157    ((value as f64) * 10_000.0).round() / 10_000.0
158}