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. Раскладка (v3) идентична байт-в-байт —
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        fn write_row(buf: &mut Vec<u8>, schema: &BlockSchema, fields: &[FieldValue]) {
135            for (i, field) in schema.fields.iter().enumerate() {
136                write_field(buf, field.ty, fields[i]);
137            }
138        }
139
140        match block {
141            Block::Indexed8(items) => {
142                buf.push(items.len() as u8);
143
144                for (id, row) in items {
145                    buf.push(*id);
146
147                    match row {
148                        None => buf.push(0),
149                        Some(fields) => {
150                            buf.push(1);
151                            write_row(buf, schema, fields);
152                        }
153                    }
154                }
155            }
156            Block::Indexed32(items) => {
157                push_u16(buf, items.len() as u16);
158
159                for (id, row) in items {
160                    push_u32(buf, *id);
161
162                    match row {
163                        None => buf.push(0),
164                        Some(fields) => {
165                            buf.push(1);
166                            write_row(buf, schema, fields);
167                        }
168                    }
169                }
170            }
171            Block::List16(items) => {
172                push_u16(buf, items.len() as u16);
173
174                for fields in items {
175                    write_row(buf, schema, fields);
176                }
177            }
178            Block::IndexedNoNull8(items) => {
179                buf.push(items.len() as u8);
180
181                for (index, fields) in items {
182                    buf.push(*index);
183                    write_row(buf, schema, fields);
184                }
185            }
186        }
187    }
188
189    /// Собирает кадр пользователя: заголовок + копия тела. Возвращает срез
190    /// внутреннего буфера — JS читает его zero-copy из памяти WASM.
191    pub fn pack_frame(
192        &mut self,
193        server_time: f64,
194        seq: u32,
195        camera: Option<&CameraData>,
196        player: Option<&PlayerBlock>,
197    ) -> &[u8] {
198        let frame = &mut self.frame;
199
200        frame.clear();
201        frame.push(self.cfg.port);
202        frame.push(self.cfg.version);
203        push_u32(frame, seq);
204        push_f64(frame, server_time);
205
206        let mut flags = 0u8;
207        let shake = camera.and_then(|camera| camera.shake.as_deref());
208
209        if let Some(camera) = camera {
210            flags |= CAMERA_FLAG_HAS_CAMERA;
211
212            if camera.force_reset {
213                flags |= CAMERA_FLAG_FORCE_RESET;
214            }
215
216            if shake.is_some() {
217                flags |= CAMERA_FLAG_HAS_SHAKE;
218            }
219        }
220
221        if player.is_some() {
222            flags |= CAMERA_FLAG_HAS_PLAYER;
223        }
224
225        frame.push(flags);
226
227        if let Some(camera) = camera {
228            push_f32(frame, camera.x);
229            push_f32(frame, camera.y);
230        }
231
232        if let Some(shake) = shake {
233            frame.push(shake.len() as u8);
234            frame.extend_from_slice(shake.as_bytes());
235        }
236
237        if let Some(player) = player {
238            frame.push(player.game_id);
239            push_u32(frame, player.input_seq);
240
241            for value in player.state {
242                push_f32(frame, value);
243            }
244
245            frame.push(player.centering as u8);
246        }
247
248        frame.extend_from_slice(&self.body);
249
250        frame
251    }
252
253    pub fn frame_bytes(&self) -> &[u8] {
254        &self.frame
255    }
256
257    pub fn body_len(&self) -> usize {
258        self.body.len()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::config::test_support::{tanks_schema, tracers_schema};
266    use indexmap::IndexMap;
267
268    fn test_config() -> SnapshotConfig {
269        let mut keys = IndexMap::new();
270
271        keys.insert("m1".to_string(), tanks_schema(1));
272        keys.insert("w1".to_string(), tracers_schema(2));
273
274        SnapshotConfig {
275            version: 3,
276            port: 5,
277            keys,
278        }
279    }
280
281    #[test]
282    fn frame_header_layout() {
283        let mut packer = SnapshotPacker::new(test_config());
284
285        packer.pack_body(&[]).unwrap();
286
287        let frame = packer.pack_frame(
288            1234.5,
289            42,
290            Some(&CameraData {
291                x: 10.5,
292                y: -3.25,
293                force_reset: true,
294                shake: Some("20:200".to_string()),
295            }),
296            None,
297        );
298
299        assert_eq!(frame[0], 5); // port
300        assert_eq!(frame[1], 3); // версия
301        assert_eq!(u32::from_be_bytes(frame[2..6].try_into().unwrap()), 42);
302        assert_eq!(
303            f64::from_be_bytes(frame[6..14].try_into().unwrap()),
304            1234.5
305        );
306        // hasCamera | forceReset | hasShake
307        assert_eq!(frame[14], 1 | 2 | 4);
308        assert_eq!(f32::from_be_bytes(frame[15..19].try_into().unwrap()), 10.5);
309        assert_eq!(frame[23], 6); // длина строки shake
310        assert_eq!(&frame[24..30], b"20:200");
311    }
312
313    #[test]
314    fn unknown_key_is_error() {
315        let mut packer = SnapshotPacker::new(test_config());
316        let result = packer.pack_body(&[("zzz".to_string(), Block::List16(vec![]))]);
317
318        assert!(result.is_err());
319    }
320
321    #[test]
322    fn tank_block_layout() {
323        let mut packer = SnapshotPacker::new(test_config());
324
325        packer
326            .pack_body(&[(
327                "m1".to_string(),
328                Block::Indexed8(vec![
329                    (
330                        7,
331                        Some(vec![
332                            FieldValue::F32(1.0),
333                            FieldValue::F32(2.0),
334                            FieldValue::F32(3.0),
335                            FieldValue::F32(4.0),
336                            FieldValue::F32(5.0),
337                            FieldValue::F32(6.0),
338                            FieldValue::F32(7.0),
339                            FieldValue::U8(3),
340                            FieldValue::U8(2),
341                            FieldValue::U8(1),
342                        ]),
343                    ),
344                    (9, None),
345                ]),
346            )])
347            .unwrap();
348
349        let frame = packer.pack_frame(0.0, 0, None, None).to_vec();
350        let body = &frame[15..]; // заголовок без камеры/игрока: 15 байт
351
352        assert_eq!(body[0], 1); // id ключа m1
353        assert_eq!(body[1], 2); // количество танков
354        assert_eq!(body[2], 7); // gameId
355        assert_eq!(body[3], 1); // hasData
356        // 7 float + condition/size/team
357        assert_eq!(body[4 + 28], 3);
358        assert_eq!(body[4 + 29], 2);
359        assert_eq!(body[4 + 30], 1);
360        assert_eq!(body[4 + 31], 9); // второй танк
361        assert_eq!(body[4 + 32], 0); // null-маркер
362    }
363}