Skip to main content

vimp_engine_core/client/
unpack.rs

1//! Декодер бинарного кадра v3 — порт unpackFrame из src/lib/snapshotCodec.js
2//! (срез 2.6). Раскладка зеркальна упаковке core/src/snapshot.rs (big-endian);
3//! float снапшота восстанавливаются повторным округлением до 2 знаков
4//! (round2), player-блок читается без округления (точность нужна предикту).
5
6use indexmap::IndexMap;
7use serde_json::{Map, Value, json};
8
9use crate::config::{BlockKind, BlockSchema, FieldType, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
10use crate::physics::round2;
11
12const CAMERA_FLAG_HAS_CAMERA: u8 = 1;
13const CAMERA_FLAG_FORCE_RESET: u8 = 2;
14const CAMERA_FLAG_HAS_SHAKE: u8 = 4;
15const CAMERA_FLAG_HAS_PLAYER: u8 = 8;
16
17/// Камера кадра: [x, y, forceReset?, shake?] из JS-формы.
18#[derive(Clone)]
19pub struct DecodedCamera {
20    pub x: f32,
21    pub y: f32,
22    pub force_reset: bool,
23    pub shake: Option<String>,
24}
25
26/// Player-блок предикшена играющего (без округления).
27#[derive(Clone)]
28pub struct DecodedPlayer {
29    pub game_id: u8,
30    pub input_seq: u32,
31    pub state: [f32; PLAYER_STATE_LEN],
32    pub centering: bool,
33}
34
35/// Данные блока по kind — форма (см. `BlockKind`), не игровая сущность;
36/// поля строки — generic `Vec<FieldValue>` в порядке `schema.fields`
37/// (зеркало BLOCK_READERS snapshotCodec.js).
38#[derive(Clone)]
39pub enum BlockData {
40    /// id → поля строки | None (null-маркер удаления)
41    Indexed8(IndexMap<u8, Option<Vec<FieldValue>>>),
42    /// id → поля строки | None (null-маркер удаления)
43    Indexed32(IndexMap<u32, Option<Vec<FieldValue>>>),
44    List16(Vec<Vec<FieldValue>>),
45    /// индекс → поля строки
46    IndexedNoNull8(IndexMap<u8, Vec<FieldValue>>),
47}
48
49/// Блок снапшота с ключом реестра opcodes.js.
50#[derive(Clone)]
51pub struct DecodedBlock {
52    pub key: String,
53    pub key_id: u8,
54    pub data: BlockData,
55}
56
57/// Тело снапшота: блоки в порядке следования в кадре.
58#[derive(Clone, Default)]
59pub struct DecodedSnapshot {
60    pub blocks: Vec<DecodedBlock>,
61}
62
63impl DecodedSnapshot {
64    pub fn block_by_key(&self, key: &str) -> Option<&BlockData> {
65        self.blocks
66            .iter()
67            .find(|block| block.key == key)
68            .map(|block| &block.data)
69    }
70}
71
72/// Распакованный кадр v3.
73pub struct DecodedFrame {
74    pub port: u8,
75    pub seq: u32,
76    pub server_time: f64,
77    pub camera: Option<DecodedCamera>,
78    pub player: Option<DecodedPlayer>,
79    pub snapshot: DecodedSnapshot,
80}
81
82pub enum UnpackError {
83    /// Версия формата кадра не совпадает — кадр отбрасывается.
84    WrongVersion,
85    /// Кадр обрывается посреди блока — повреждённые данные.
86    Truncated,
87}
88
89/// Курсор чтения big-endian с проверкой границ.
90struct Reader<'a> {
91    data: &'a [u8],
92    offset: usize,
93}
94
95impl<'a> Reader<'a> {
96    fn new(data: &'a [u8]) -> Self {
97        Self { data, offset: 0 }
98    }
99
100    fn remaining(&self) -> usize {
101        self.data.len() - self.offset
102    }
103
104    fn take(&mut self, len: usize) -> Result<&'a [u8], UnpackError> {
105        if self.remaining() < len {
106            return Err(UnpackError::Truncated);
107        }
108
109        let slice = &self.data[self.offset..self.offset + len];
110
111        self.offset += len;
112        Ok(slice)
113    }
114
115    fn u8(&mut self) -> Result<u8, UnpackError> {
116        Ok(self.take(1)?[0])
117    }
118
119    fn u16(&mut self) -> Result<u16, UnpackError> {
120        Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap()))
121    }
122
123    fn u32(&mut self) -> Result<u32, UnpackError> {
124        Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap()))
125    }
126
127    fn f32_raw(&mut self) -> Result<f32, UnpackError> {
128        Ok(f32::from_be_bytes(self.take(4)?.try_into().unwrap()))
129    }
130
131    /// Float снапшота: восстановление round2-значения (readFloat из JS).
132    fn f32_round2(&mut self) -> Result<f32, UnpackError> {
133        Ok(round2(self.f32_raw()?))
134    }
135
136    fn f64(&mut self) -> Result<f64, UnpackError> {
137        Ok(f64::from_be_bytes(self.take(8)?.try_into().unwrap()))
138    }
139}
140
141/// Читает одно поле строки по типу схемы (интерпретатор `FieldSchema`,
142/// зеркало `snapshot::write_field`). Float снапшота — с округлением
143/// round2 (readFloat из JS-версии).
144fn read_field(r: &mut Reader, ty: FieldType) -> Result<FieldValue, UnpackError> {
145    Ok(match ty {
146        FieldType::F32 => FieldValue::F32(r.f32_round2()?),
147        FieldType::U8 => FieldValue::U8(r.u8()?),
148        FieldType::U16 => FieldValue::U16(r.u16()?),
149        FieldType::U32 => FieldValue::U32(r.u32()?),
150    })
151}
152
153/// Распаковывает бинарный кадр (порт unpackFrame).
154pub fn unpack_frame(data: &[u8], cfg: &SnapshotConfig) -> Result<DecodedFrame, UnpackError> {
155    let mut r = Reader::new(data);
156
157    let port = r.u8()?;
158    let version = r.u8()?;
159
160    if version != cfg.version {
161        return Err(UnpackError::WrongVersion);
162    }
163
164    let seq = r.u32()?;
165    let server_time = r.f64()?;
166    let flags = r.u8()?;
167
168    let mut camera = None;
169
170    if flags & CAMERA_FLAG_HAS_CAMERA != 0 {
171        let x = r.f32_round2()?;
172        let y = r.f32_round2()?;
173        let force_reset = flags & CAMERA_FLAG_FORCE_RESET != 0;
174
175        let shake = if flags & CAMERA_FLAG_HAS_SHAKE != 0 {
176            let len = r.u8()? as usize;
177            let bytes = r.take(len)?;
178
179            Some(String::from_utf8_lossy(bytes).into_owned())
180        } else {
181            None
182        };
183
184        camera = Some(DecodedCamera {
185            x,
186            y,
187            force_reset,
188            shake,
189        });
190    }
191
192    let mut player = None;
193
194    if flags & CAMERA_FLAG_HAS_PLAYER != 0 {
195        let game_id = r.u8()?;
196        let input_seq = r.u32()?;
197        let mut state = [0.0f32; PLAYER_STATE_LEN];
198
199        for value in &mut state {
200            *value = r.f32_raw()?; // без округления (предикшен)
201        }
202
203        let centering = r.u8()? == 1;
204
205        player = Some(DecodedPlayer {
206            game_id,
207            input_seq,
208            state,
209            centering,
210        });
211    }
212
213    let mut snapshot = DecodedSnapshot::default();
214
215    while r.remaining() > 0 {
216        let key_id = r.u8()?;
217
218        // неизвестный id блока — остаток кадра отбрасывается (как в JS)
219        let Some((key, info)) = cfg.keys.iter().find(|(_, info)| info.id == key_id) else {
220            break;
221        };
222
223        let data = match info.kind {
224            BlockKind::Indexed8 => read_indexed8(&mut r, info)?,
225            BlockKind::Indexed32 => read_indexed32(&mut r, info)?,
226            BlockKind::List16 => read_list16(&mut r, info)?,
227            BlockKind::IndexedNoNull8 => read_indexed_no_null8(&mut r, info)?,
228        };
229
230        snapshot.blocks.push(DecodedBlock {
231            key: key.clone(),
232            key_id,
233            data,
234        });
235    }
236
237    Ok(DecodedFrame {
238        port,
239        seq,
240        server_time,
241        camera,
242        player,
243        snapshot,
244    })
245}
246
247/// Читает строку по позиционной схеме `schema.fields` — generic для любого
248/// kind, раскладка полей внутри строки не зависит от формы блока.
249fn read_row(r: &mut Reader, schema: &BlockSchema) -> Result<Vec<FieldValue>, UnpackError> {
250    schema
251        .fields
252        .iter()
253        .map(|field| read_field(r, field.ty))
254        .collect()
255}
256
257fn read_indexed8(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
258    let count = r.u8()?;
259    let mut result = IndexMap::new();
260
261    for _ in 0..count {
262        let id = r.u8()?;
263
264        if r.u8()? == 0 {
265            result.insert(id, None);
266            continue;
267        }
268
269        result.insert(id, Some(read_row(r, schema)?));
270    }
271
272    Ok(BlockData::Indexed8(result))
273}
274
275fn read_indexed32(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
276    let count = r.u16()?;
277    let mut result = IndexMap::new();
278
279    for _ in 0..count {
280        let id = r.u32()?;
281
282        if r.u8()? == 0 {
283            result.insert(id, None);
284            continue;
285        }
286
287        result.insert(id, Some(read_row(r, schema)?));
288    }
289
290    Ok(BlockData::Indexed32(result))
291}
292
293fn read_list16(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
294    let count = r.u16()?;
295    let mut result = Vec::with_capacity(count as usize);
296
297    for _ in 0..count {
298        result.push(read_row(r, schema)?);
299    }
300
301    Ok(BlockData::List16(result))
302}
303
304fn read_indexed_no_null8(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
305    let count = r.u8()?;
306    let mut result = IndexMap::new();
307
308    for _ in 0..count {
309        let index = r.u8()?;
310
311        result.insert(index, read_row(r, schema)?);
312    }
313
314    Ok(BlockData::IndexedNoNull8(result))
315}
316
317// ***** JSON-сериализация (формы, идентичные unpackFrame) ***** //
318
319/// round2-значение f32 → точный двухзначный f64 для JSON
320/// (JS readFloat возвращал round(f32·100)/100 в f64).
321pub fn round2_f64(v: f32) -> f64 {
322    ((v as f64) * 100.0).round() / 100.0
323}
324
325/// u32 → base36 (ключи бомб; JS id.toString(36)).
326pub fn to_base36(mut v: u32) -> String {
327    const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
328
329    if v == 0 {
330        return "0".to_string();
331    }
332
333    let mut out = Vec::new();
334
335    while v > 0 {
336        out.push(DIGITS[(v % 36) as usize]);
337        v /= 36;
338    }
339
340    out.reverse();
341    String::from_utf8(out).unwrap()
342}
343
344/// Камера в JS-форму: 0 | [x, y] | [x, y, true] | [x, y, ..., shake].
345pub fn camera_to_json(camera: Option<&DecodedCamera>) -> Value {
346    let Some(camera) = camera else {
347        return json!(0);
348    };
349
350    let mut arr = vec![
351        Value::from(round2_f64(camera.x)),
352        Value::from(round2_f64(camera.y)),
353    ];
354
355    if camera.force_reset {
356        arr.push(Value::from(true));
357    }
358
359    if let Some(shake) = &camera.shake {
360        // shake живёт в camera[3]; без forceReset слот [2] — null (как в JS)
361        while arr.len() < 3 {
362            arr.push(Value::Null);
363        }
364
365        arr.push(Value::from(shake.clone()));
366    }
367
368    Value::Array(arr)
369}
370
371fn field_value_to_json(value: FieldValue) -> Value {
372    match value {
373        FieldValue::F32(v) => Value::from(round2_f64(v)),
374        FieldValue::U8(v) => Value::from(v),
375        FieldValue::U16(v) => Value::from(v),
376        FieldValue::U32(v) => Value::from(v),
377    }
378}
379
380/// Строка блока в JS-форму: позиционный массив значений полей (порядок —
381/// `schema.fields`), как в JS-версии snapshotCodec.js.
382fn row_to_json(fields: &[FieldValue]) -> Value {
383    Value::Array(fields.iter().copied().map(field_value_to_json).collect())
384}
385
386/// Тело снапшота в JS-форму { ключ: данные } для parse-конвейера клиента.
387/// Форма контейнера по kind — не по игровой сущности: `Indexed8`/`Indexed32`
388/// дают объект id → строка|null, `List16` — массив строк, `IndexedNoNull8`
389/// — объект `d{index}` → строка (сохраняет исторический формат ключей
390/// динамических элементов карты).
391pub fn snapshot_to_json(snapshot: &DecodedSnapshot) -> Map<String, Value> {
392    let mut result = Map::new();
393
394    for block in &snapshot.blocks {
395        let value = match &block.data {
396            BlockData::Indexed8(items) => {
397                let mut map = Map::new();
398
399                for (id, row) in items {
400                    map.insert(
401                        id.to_string(),
402                        row.as_deref().map_or(Value::Null, row_to_json),
403                    );
404                }
405
406                Value::Object(map)
407            }
408            BlockData::Indexed32(items) => {
409                let mut map = Map::new();
410
411                for (id, row) in items {
412                    map.insert(
413                        to_base36(*id),
414                        row.as_deref().map_or(Value::Null, row_to_json),
415                    );
416                }
417
418                Value::Object(map)
419            }
420            BlockData::List16(items) => {
421                Value::Array(items.iter().map(|fields| row_to_json(fields)).collect())
422            }
423            BlockData::IndexedNoNull8(items) => {
424                let mut map = Map::new();
425
426                for (index, fields) in items {
427                    map.insert(format!("d{index}"), row_to_json(fields));
428                }
429
430                Value::Object(map)
431            }
432        };
433
434        result.insert(block.key.clone(), value);
435    }
436
437    result
438}
439
440/// Полный кадр в JSON-форму unpackFrame — для тестов и харнесса
441/// (ClientCore.decode_frame): { port, seq, serverTime, camera, player, snapshot }.
442pub fn frame_to_json(frame: &DecodedFrame) -> Value {
443    let player = frame.player.as_ref().map_or(Value::Null, |p| {
444        json!({
445            "gameId": p.game_id,
446            "inputSeq": p.input_seq,
447            // без округления: f64-расширение Float32 (как getFloat32 в JS)
448            "state": p.state.iter().map(|v| *v as f64).collect::<Vec<f64>>(),
449            "centering": p.centering,
450        })
451    });
452
453    json!({
454        "port": frame.port,
455        "seq": frame.seq,
456        "serverTime": frame.server_time,
457        "camera": camera_to_json(frame.camera.as_ref()),
458        "player": player,
459        "snapshot": Value::Object(snapshot_to_json(&frame.snapshot)),
460    })
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::config::test_support::full_snapshot_config;
467    use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
468
469    // round-trip против упаковщика того же crate: раскладка не может
470    // разойтись между pack и unpack по построению, тесты фиксируют формы
471    fn test_config() -> SnapshotConfig {
472        full_snapshot_config(3, 5)
473    }
474
475    fn packed_frame(
476        blocks: &[(String, Block)],
477        camera: Option<&CameraData>,
478        player: Option<&PlayerBlock>,
479    ) -> Vec<u8> {
480        let mut packer = SnapshotPacker::new(test_config());
481
482        packer.pack_body(blocks).unwrap();
483        packer.pack_frame(1234.5, 42, camera, player).to_vec()
484    }
485
486    fn tank_row(floats: [f32; 7], condition: u8, size: u8, team: u8) -> Vec<FieldValue> {
487        let mut fields: Vec<FieldValue> = floats.iter().copied().map(FieldValue::F32).collect();
488
489        fields.push(FieldValue::U8(condition));
490        fields.push(FieldValue::U8(size));
491        fields.push(FieldValue::U8(team));
492        fields
493    }
494
495    fn tracer_row(floats: [f32; 6], was_hit: bool, shooter: u8) -> Vec<FieldValue> {
496        let mut fields: Vec<FieldValue> = floats.iter().copied().map(FieldValue::F32).collect();
497
498        fields.push(FieldValue::U8(was_hit as u8));
499        fields.push(FieldValue::U8(shooter));
500        fields
501    }
502
503    fn bomb_row(x: f32, y: f32, angle: f32, size: u8, time: u16, owner: u8) -> Vec<FieldValue> {
504        vec![
505            FieldValue::F32(x),
506            FieldValue::F32(y),
507            FieldValue::F32(angle),
508            FieldValue::U8(size),
509            FieldValue::U16(time),
510            FieldValue::U8(owner),
511        ]
512    }
513
514    fn explosion_row(x: f32, y: f32, radius: f32) -> Vec<FieldValue> {
515        vec![FieldValue::F32(x), FieldValue::F32(y), FieldValue::F32(radius)]
516    }
517
518    fn field_f32(fields: &[FieldValue], i: usize) -> f32 {
519        match fields[i] {
520            FieldValue::F32(v) => v,
521            _ => panic!("поле {i} не F32"),
522        }
523    }
524
525    #[test]
526    fn full_frame_round_trip() {
527        let blocks = vec![
528            (
529                "m1".to_string(),
530                Block::Indexed8(vec![
531                    (
532                        2,
533                        Some(tank_row(
534                            [10.567, -3.141, 1.5, 0.25, 100.0, -50.5, 1.2],
535                            3,
536                            2,
537                            1,
538                        )),
539                    ),
540                    (3, None),
541                ]),
542            ),
543            (
544                "w1".to_string(),
545                Block::List16(vec![tracer_row(
546                    [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
547                    true,
548                    7,
549                )]),
550            ),
551            (
552                "w2".to_string(),
553                Block::Indexed32(vec![
554                    (
555                        // 'a1f' в base36
556                        u32::from_str_radix("a1f", 36).unwrap(),
557                        Some(bomb_row(5.5, 6.5, 0.0, 8, 300, 2)),
558                    ),
559                    (1, None),
560                ]),
561            ),
562            (
563                "w2e".to_string(),
564                Block::List16(vec![explosion_row(100.0, 200.0, 50.0)]),
565            ),
566            (
567                "c1".to_string(),
568                Block::IndexedNoNull8(vec![(0, explosion_row(10.0, 20.0, 0.5))]),
569            ),
570        ];
571
572        let camera = CameraData {
573            x: 10.5,
574            y: -3.25,
575            force_reset: true,
576            shake: Some("20:200".to_string()),
577        };
578        let player = PlayerBlock {
579            game_id: 2,
580            input_seq: 77,
581            state: [10.56789, 2.0, 0.5, 3.0, 4.0, 0.1, 0.2, 0.7],
582            centering: true,
583        };
584
585        let data = packed_frame(&blocks, Some(&camera), Some(&player));
586        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
587
588        assert_eq!(frame.port, 5);
589        assert_eq!(frame.seq, 42);
590        assert_eq!(frame.server_time, 1234.5);
591
592        // камера: координаты round2, флаги
593        let cam = frame.camera.as_ref().unwrap();
594
595        assert_eq!(cam.x, 10.5);
596        assert_eq!(cam.y, -3.25);
597        assert!(cam.force_reset);
598        assert_eq!(cam.shake.as_deref(), Some("20:200"));
599
600        // player-блок: без округления (точное f32-значение)
601        let p = frame.player.as_ref().unwrap();
602
603        assert_eq!(p.game_id, 2);
604        assert_eq!(p.input_seq, 77);
605        assert_eq!(p.state[0], 10.56789f32);
606        assert!(p.centering);
607
608        // танки: float снапшота восстановлены округлением до 2 знаков
609        let Some(BlockData::Indexed8(tanks)) = frame.snapshot.block_by_key("m1") else {
610            panic!("нет блока m1");
611        };
612        let row = tanks[&2].as_ref().unwrap();
613
614        assert_eq!(field_f32(row, 0), 10.57);
615        assert_eq!(field_f32(row, 1), -3.14);
616
617        match (row[7], row[8], row[9]) {
618            (FieldValue::U8(condition), FieldValue::U8(size), FieldValue::U8(team)) => {
619                assert_eq!((condition, size, team), (3, 2, 1));
620            }
621            _ => panic!("неожиданные типы полей tank"),
622        }
623        assert!(tanks[&3].is_none()); // null-маркер
624
625        let Some(BlockData::List16(tracers)) = frame.snapshot.block_by_key("w1") else {
626            panic!("нет блока w1");
627        };
628
629        match tracers[0][6] {
630            FieldValue::U8(v) => assert_eq!(v, 1),
631            _ => panic!("поле 6 не U8"),
632        }
633        match tracers[0][7] {
634            FieldValue::U8(v) => assert_eq!(v, 7),
635            _ => panic!("поле 7 не U8"),
636        }
637
638        let Some(BlockData::Indexed32(bombs)) = frame.snapshot.block_by_key("w2") else {
639            panic!("нет блока w2");
640        };
641        let bomb = bombs[&u32::from_str_radix("a1f", 36).unwrap()]
642            .as_ref()
643            .unwrap();
644
645        match (bomb[3], bomb[4], bomb[5]) {
646            (FieldValue::U8(size), FieldValue::U16(time), FieldValue::U8(owner)) => {
647                assert_eq!((size, time, owner), (8, 300, 2));
648            }
649            _ => panic!("неожиданные типы полей bomb"),
650        }
651        assert!(bombs[&1].is_none());
652
653        let Some(BlockData::List16(explosions)) = frame.snapshot.block_by_key("w2e") else {
654            panic!("нет блока w2e");
655        };
656
657        assert_eq!(
658            (
659                field_f32(&explosions[0], 0),
660                field_f32(&explosions[0], 1),
661                field_f32(&explosions[0], 2)
662            ),
663            (100.0, 200.0, 50.0)
664        );
665
666        let Some(BlockData::IndexedNoNull8(dynamics)) = frame.snapshot.block_by_key("c1") else {
667            panic!("нет блока c1");
668        };
669
670        let dyn_row = &dynamics[&0];
671
672        assert_eq!(
673            (
674                field_f32(dyn_row, 0),
675                field_f32(dyn_row, 1),
676                field_f32(dyn_row, 2)
677            ),
678            (10.0, 20.0, 0.5)
679        );
680    }
681
682    #[test]
683    fn wrong_version_is_rejected() {
684        let mut data = packed_frame(&[], None, None);
685
686        data[1] = 99;
687        assert!(matches!(
688            unpack_frame(&data, &test_config()),
689            Err(UnpackError::WrongVersion)
690        ));
691    }
692
693    #[test]
694    fn truncated_frame_is_error() {
695        let data = packed_frame(
696            &[(
697                "m1".to_string(),
698                Block::Indexed8(vec![(1, Some(tank_row([0.0; 7], 3, 2, 1)))]),
699            )],
700            None,
701            None,
702        );
703
704        assert!(matches!(
705            unpack_frame(&data[..data.len() - 4], &test_config()),
706            Err(UnpackError::Truncated)
707        ));
708    }
709
710    #[test]
711    fn unknown_block_id_drops_rest() {
712        let mut data = packed_frame(
713            &[(
714                "w2e".to_string(),
715                Block::List16(vec![explosion_row(1.0, 2.0, 3.0)]),
716            )],
717            None,
718            None,
719        );
720
721        // подмена id блока на незарегистрированный
722        data[15] = 200;
723
724        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
725
726        assert!(frame.snapshot.blocks.is_empty());
727    }
728
729    #[test]
730    fn base36_matches_js() {
731        assert_eq!(to_base36(0), "0");
732        assert_eq!(to_base36(35), "z");
733        assert_eq!(to_base36(u32::from_str_radix("a1f", 36).unwrap()), "a1f");
734    }
735
736    #[test]
737    fn camera_json_forms() {
738        assert_eq!(camera_to_json(None), json!(0));
739
740        let plain = DecodedCamera {
741            x: 1.5,
742            y: 2.5,
743            force_reset: false,
744            shake: None,
745        };
746
747        assert_eq!(camera_to_json(Some(&plain)), json!([1.5, 2.5]));
748
749        let reset = DecodedCamera {
750            force_reset: true,
751            ..plain.clone()
752        };
753
754        assert_eq!(camera_to_json(Some(&reset)), json!([1.5, 2.5, true]));
755
756        // shake без reset: слот [2] — null (как в JS-массиве с «дыркой»)
757        let shake = DecodedCamera {
758            shake: Some("20:200".to_string()),
759            ..plain
760        };
761
762        assert_eq!(
763            camera_to_json(Some(&shake)),
764            json!([1.5, 2.5, null, "20:200"])
765        );
766    }
767
768    #[test]
769    fn snapshot_json_matches_unpack_frame_forms() {
770        let blocks = vec![
771            (
772                "m1".to_string(),
773                Block::Indexed8(vec![
774                    (
775                        2,
776                        Some(tank_row([51.28, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], 3, 2, 1)),
777                    ),
778                    (3, None),
779                ]),
780            ),
781            (
782                "w2".to_string(),
783                Block::Indexed32(vec![(
784                    u32::from_str_radix("a1f", 36).unwrap(),
785                    Some(bomb_row(1.0, 2.0, 0.0, 8, 300, 2)),
786                )]),
787            ),
788        ];
789        let data = packed_frame(&blocks, None, None);
790        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
791        let game = Value::Object(snapshot_to_json(&frame.snapshot));
792
793        // round2-значения сериализуются точными двухзначными числами
794        assert_eq!(
795            game,
796            json!({
797                "m1": { "2": [51.28, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 3, 2, 1], "3": null },
798                "w2": { "a1f": [1.0, 2.0, 0.0, 8, 300, 2] },
799            })
800        );
801    }
802}