Skip to main content

vimp_engine_core/
snapshot.rs

1use crate::config::{
2    BlockKind, BlockSchema, FieldType, FieldValue, PLAYER_STATE_LEN, SnapshotConfig,
3};
4
5// Бинарный пакер snapshot-кадра: порт SnapshotPacker из
6// src/lib/snapshotCodec.js. Раскладка (v5) идентична байт-в-байт —
7// клиент распаковывает существующим unpackFrame. Big-endian.
8
9const CAMERA_FLAG_HAS_CAMERA: u8 = 1;
10const CAMERA_FLAG_FORCE_RESET: u8 = 2;
11const CAMERA_FLAG_HAS_SHAKE: u8 = 4;
12const CAMERA_FLAG_HAS_PLAYER: u8 = 8;
13
14/// Типизированные блоки тела снапшота — форма (см. `BlockKind`), не
15/// игровая сущность; движок не знает, что строка блока значит «танк» или
16/// «бомба», только как её упаковать. Раскладка полей внутри строки — по
17/// `schema.fields`, позиционно.
18pub enum Block {
19    /// (id, поля строки | None = удаление с полотна)
20    Indexed8(Vec<(u8, Option<Vec<FieldValue>>)>),
21    /// (id, поля строки | None = удаление с полотна)
22    Indexed32(Vec<(u32, Option<Vec<FieldValue>>)>),
23    /// поля строки, без id
24    List16(Vec<Vec<FieldValue>>),
25    /// (индекс, поля строки)
26    IndexedNoNull8(Vec<(u8, Vec<FieldValue>)>),
27}
28
29impl Block {
30    fn kind(&self) -> BlockKind {
31        match self {
32            Block::Indexed8(_) => BlockKind::Indexed8,
33            Block::Indexed32(_) => BlockKind::Indexed32,
34            Block::List16(_) => BlockKind::List16,
35            Block::IndexedNoNull8(_) => BlockKind::IndexedNoNull8,
36        }
37    }
38}
39
40/// Камера per-user кадра: [x, y, forceReset?, shake?] из JS-версии.
41pub struct CameraData {
42    pub x: f32,
43    pub y: f32,
44    pub force_reset: bool,
45    pub shake: Option<String>,
46}
47
48/// Player-блок предикшена играющего.
49pub struct PlayerBlock {
50    pub game_id: u8,
51    pub input_seq: u32,
52    pub state: [f32; PLAYER_STATE_LEN],
53    pub centering: bool,
54}
55
56pub struct SnapshotPacker {
57    cfg: SnapshotConfig,
58    body: Vec<u8>,
59    frame: Vec<u8>,
60}
61
62fn push_u16(buf: &mut Vec<u8>, v: u16) {
63    buf.extend_from_slice(&v.to_be_bytes());
64}
65
66fn push_u32(buf: &mut Vec<u8>, v: u32) {
67    buf.extend_from_slice(&v.to_be_bytes());
68}
69
70fn push_f32(buf: &mut Vec<u8>, v: f32) {
71    buf.extend_from_slice(&v.to_be_bytes());
72}
73
74fn push_f64(buf: &mut Vec<u8>, v: f64) {
75    buf.extend_from_slice(&v.to_be_bytes());
76}
77
78/// Пишет одно поле строки по типу схемы (интерпретатор `FieldSchema`).
79/// Ветка `_` недостижима, пока `SnapshotConfig::validate()` вызывается на
80/// границе конструирования (`GameCore::new`/`ClientCore::new`) — паникуем
81/// явно вместо тихого no-op, чтобы регресс защиты проявился сразу, а не
82/// усечённым кадром в release-WASM.
83fn write_field(buf: &mut Vec<u8>, ty: FieldType, value: FieldValue) {
84    match (ty, value) {
85        (FieldType::F32, FieldValue::F32(v)) => push_f32(buf, v),
86        (FieldType::U8, FieldValue::U8(v)) => buf.push(v),
87        (FieldType::U16, FieldValue::U16(v)) => push_u16(buf, v),
88        (FieldType::U32, FieldValue::U32(v)) => push_u32(buf, v),
89        _ => unreachable!(
90            "[core snapshot] тип поля не совпадает со схемой — validate() должен был это отловить"
91        ),
92    }
93}
94
95impl SnapshotPacker {
96    pub fn new(cfg: SnapshotConfig) -> Self {
97        Self {
98            cfg,
99            body: Vec::with_capacity(4096),
100            frame: Vec::with_capacity(4096),
101        }
102    }
103
104    /// Пакует блоки сущностей (broadcast-часть, один раз за отправку).
105    /// Незарегистрированный ключ — ошибка (зеркало проверки packBody).
106    pub fn pack_body(&mut self, blocks: &[(String, Block)]) -> Result<(), String> {
107        self.body.clear();
108
109        for (key, block) in blocks {
110            let schema = self.cfg.keys.get(key).ok_or_else(|| {
111                format!(
112                    "[core snapshot] Неизвестный ключ снапшота '{key}': \
113                     зарегистрируйте его в src/config/opcodes.js"
114                )
115            })?;
116
117            if schema.kind != block.kind() {
118                return Err(format!(
119                    "[core snapshot] Раскладка блока '{key}' не совпадает с kind из opcodes.js"
120                ));
121            }
122
123            self.body.push(schema.id);
124            Self::write_block(&mut self.body, schema, block);
125        }
126
127        Ok(())
128    }
129
130    /// Интерпретатор схемы: форма блока (ширина count/id, null-маркер)
131    /// зависит от `kind` (см. `BlockKind`), раскладка полей строки —
132    /// от `schema.fields` (см. `FieldSchema`).
133    fn write_block(buf: &mut Vec<u8>, schema: &BlockSchema, block: &Block) {
134        // Строка со схемой без хвоста пишется как есть. Со схемой с хвостом
135        // (`optionalFrom`) строка начинается с флаг-байта: 1 — хвост дальше
136        // есть, 0 — строка обрывается на обязательной части (кадр v4,
137        // покоящееся тело динамики не платит за скорости). Наличие хвоста
138        // задаёт длина переданной строки: `required_len()` или полная.
139        fn write_row(buf: &mut Vec<u8>, schema: &BlockSchema, fields: &[FieldValue]) {
140            debug_assert!(
141                fields.len() == schema.fields.len() || fields.len() == schema.required_len(),
142                "[core snapshot] длина строки {} не равна ни полной ширине схемы {}, \
143                 ни её обязательной части {}",
144                fields.len(),
145                schema.fields.len(),
146                schema.required_len()
147            );
148
149            let len = if schema.optional_from.is_some() {
150                let has_tail = fields.len() > schema.required_len();
151
152                buf.push(has_tail as u8);
153
154                if has_tail {
155                    schema.fields.len()
156                } else {
157                    schema.required_len()
158                }
159            } else {
160                schema.fields.len()
161            };
162
163            for (i, field) in schema.fields.iter().take(len).enumerate() {
164                write_field(buf, field.ty, fields[i]);
165            }
166        }
167
168        match block {
169            Block::Indexed8(items) => {
170                buf.push(items.len() as u8);
171
172                for (id, row) in items {
173                    buf.push(*id);
174
175                    match row {
176                        None => buf.push(0),
177                        Some(fields) => {
178                            buf.push(1);
179                            write_row(buf, schema, fields);
180                        }
181                    }
182                }
183            }
184            Block::Indexed32(items) => {
185                push_u16(buf, items.len() as u16);
186
187                for (id, row) in items {
188                    push_u32(buf, *id);
189
190                    match row {
191                        None => buf.push(0),
192                        Some(fields) => {
193                            buf.push(1);
194                            write_row(buf, schema, fields);
195                        }
196                    }
197                }
198            }
199            Block::List16(items) => {
200                push_u16(buf, items.len() as u16);
201
202                for fields in items {
203                    write_row(buf, schema, fields);
204                }
205            }
206            Block::IndexedNoNull8(items) => {
207                buf.push(items.len() as u8);
208
209                for (index, fields) in items {
210                    buf.push(*index);
211                    write_row(buf, schema, fields);
212                }
213            }
214        }
215    }
216
217    /// Собирает кадр пользователя: заголовок + копия тела. Возвращает срез
218    /// внутреннего буфера — JS читает его zero-copy из памяти WASM.
219    pub fn pack_frame(
220        &mut self,
221        server_time: f64,
222        seq: u32,
223        camera: Option<&CameraData>,
224        player: Option<&PlayerBlock>,
225    ) -> &[u8] {
226        let frame = &mut self.frame;
227
228        frame.clear();
229        frame.push(self.cfg.port);
230        frame.push(self.cfg.version);
231        push_u32(frame, seq);
232        push_f64(frame, server_time);
233
234        let mut flags = 0u8;
235        let shake = camera.and_then(|camera| camera.shake.as_deref());
236
237        if let Some(camera) = camera {
238            flags |= CAMERA_FLAG_HAS_CAMERA;
239
240            if camera.force_reset {
241                flags |= CAMERA_FLAG_FORCE_RESET;
242            }
243
244            if shake.is_some() {
245                flags |= CAMERA_FLAG_HAS_SHAKE;
246            }
247        }
248
249        if player.is_some() {
250            flags |= CAMERA_FLAG_HAS_PLAYER;
251        }
252
253        frame.push(flags);
254
255        if let Some(camera) = camera {
256            push_f32(frame, camera.x);
257            push_f32(frame, camera.y);
258        }
259
260        if let Some(shake) = shake {
261            frame.push(shake.len() as u8);
262            frame.extend_from_slice(shake.as_bytes());
263        }
264
265        if let Some(player) = player {
266            frame.push(player.game_id);
267            push_u32(frame, player.input_seq);
268
269            for value in player.state {
270                push_f32(frame, value);
271            }
272
273            frame.push(player.centering as u8);
274        }
275
276        frame.extend_from_slice(&self.body);
277
278        frame
279    }
280
281    pub fn frame_bytes(&self) -> &[u8] {
282        &self.frame
283    }
284
285    pub fn body_len(&self) -> usize {
286        self.body.len()
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::config::test_support::{dynamics_schema, tanks_schema, tracers_schema};
294    use indexmap::IndexMap;
295
296    fn test_config() -> SnapshotConfig {
297        let mut keys = IndexMap::new();
298
299        keys.insert("m1".to_string(), tanks_schema(1));
300        keys.insert("w1".to_string(), tracers_schema(2));
301        keys.insert("c1".to_string(), dynamics_schema(5));
302
303        SnapshotConfig {
304            version: 3,
305            port: 5,
306            keys,
307        }
308    }
309
310    #[test]
311    fn frame_header_layout() {
312        let mut packer = SnapshotPacker::new(test_config());
313
314        packer.pack_body(&[]).unwrap();
315
316        let frame = packer.pack_frame(
317            1234.5,
318            42,
319            Some(&CameraData {
320                x: 10.5,
321                y: -3.25,
322                force_reset: true,
323                shake: Some("20:200".to_string()),
324            }),
325            None,
326        );
327
328        assert_eq!(frame[0], 5); // port
329        assert_eq!(frame[1], 3); // версия
330        assert_eq!(u32::from_be_bytes(frame[2..6].try_into().unwrap()), 42);
331        assert_eq!(
332            f64::from_be_bytes(frame[6..14].try_into().unwrap()),
333            1234.5
334        );
335        // hasCamera | forceReset | hasShake
336        assert_eq!(frame[14], 1 | 2 | 4);
337        assert_eq!(f32::from_be_bytes(frame[15..19].try_into().unwrap()), 10.5);
338        assert_eq!(frame[23], 6); // длина строки shake
339        assert_eq!(&frame[24..30], b"20:200");
340    }
341
342    #[test]
343    fn unknown_key_is_error() {
344        let mut packer = SnapshotPacker::new(test_config());
345        let result = packer.pack_body(&[("zzz".to_string(), Block::List16(vec![]))]);
346
347        assert!(result.is_err());
348    }
349
350    #[test]
351    fn tank_block_layout() {
352        let mut packer = SnapshotPacker::new(test_config());
353
354        packer
355            .pack_body(&[(
356                "m1".to_string(),
357                Block::Indexed8(vec![
358                    (
359                        7,
360                        Some(vec![
361                            FieldValue::F32(1.0),
362                            FieldValue::F32(2.0),
363                            FieldValue::F32(3.0),
364                            FieldValue::F32(4.0),
365                            FieldValue::F32(5.0),
366                            FieldValue::F32(6.0),
367                            FieldValue::F32(7.0),
368                            FieldValue::U8(3),
369                            FieldValue::U8(2),
370                            FieldValue::U8(1),
371                        ]),
372                    ),
373                    (9, None),
374                ]),
375            )])
376            .unwrap();
377
378        let frame = packer.pack_frame(0.0, 0, None, None).to_vec();
379        let body = &frame[15..]; // заголовок без камеры/игрока: 15 байт
380
381        assert_eq!(body[0], 1); // id ключа m1
382        assert_eq!(body[1], 2); // количество танков
383        assert_eq!(body[2], 7); // gameId
384        assert_eq!(body[3], 1); // hasData
385        // 7 float + condition/size/team
386        assert_eq!(body[4 + 28], 3);
387        assert_eq!(body[4 + 29], 2);
388        assert_eq!(body[4 + 30], 1);
389        assert_eq!(body[4 + 31], 9); // второй танк
390        assert_eq!(body[4 + 32], 0); // null-маркер
391    }
392
393    /// Раскладка строки с опциональным хвостом: индекс, флаг-байт, затем
394    /// обязательные поля и — только при флаге 1 — хвост.
395    #[test]
396    fn dynamics_optional_tail_layout() {
397        let mut packer = SnapshotPacker::new(test_config());
398        let f32s = |values: &[f32]| -> Vec<FieldValue> {
399            values.iter().copied().map(FieldValue::F32).collect()
400        };
401
402        packer
403            .pack_body(&[(
404                "c1".to_string(),
405                Block::IndexedNoNull8(vec![
406                    (0, f32s(&[1.0, 2.0, 3.0])),
407                    (1, f32s(&[4.0, 5.0, 6.0, 7.0, 8.0, 9.0])),
408                ]),
409            )])
410            .unwrap();
411
412        let frame = packer.pack_frame(0.0, 0, None, None).to_vec();
413        let body = &frame[15..]; // заголовок без камеры/игрока: 15 байт
414
415        assert_eq!(body[0], 5); // id ключа c1
416        assert_eq!(body[1], 2); // количество тел
417        assert_eq!(body[2], 0); // индекс первого
418        assert_eq!(body[3], 0); // хвоста нет
419        assert_eq!(f32::from_be_bytes(body[4..8].try_into().unwrap()), 1.0);
420
421        // первая строка заняла 2 + 12 байт, вторая идёт сразу за ней
422        assert_eq!(body[16], 1); // индекс второго
423        assert_eq!(body[17], 1); // хвост есть
424        assert_eq!(f32::from_be_bytes(body[30..34].try_into().unwrap()), 7.0);
425        assert_eq!(body.len(), 2 + (2 + 12) + (2 + 24));
426    }
427}