Skip to main content

vimp_engine_core/client/
unpack.rs

1//! Декодер бинарного кадра v5 — порт 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/// Нулевое значение поля по его типу — отсутствующий опциональный хвост
248/// читается именно так: «тело покоится», то есть нулевые скорости.
249fn zero_field(ty: FieldType) -> FieldValue {
250    match ty {
251        FieldType::F32 => FieldValue::F32(0.0),
252        FieldType::U8 => FieldValue::U8(0),
253        FieldType::U16 => FieldValue::U16(0),
254        FieldType::U32 => FieldValue::U32(0),
255    }
256}
257
258/// Читает строку по позиционной схеме `schema.fields` — generic для любого
259/// kind, раскладка полей внутри строки не зависит от формы блока.
260/// Схема с опциональным хвостом (`optionalFrom`) начинает строку флаг-байтом;
261/// при нулевом флаге хвост в кадре отсутствует и добивается нулями — наружу
262/// строка всегда отдаётся полной ширины (интерполятор и hot-буфер читают
263/// поля по индексу и переменную длину не переживут).
264fn read_row(r: &mut Reader, schema: &BlockSchema) -> Result<Vec<FieldValue>, UnpackError> {
265    let has_tail = match schema.optional_from {
266        None => true,
267        Some(_) => r.u8()? == 1,
268    };
269
270    let read_len = if has_tail {
271        schema.fields.len()
272    } else {
273        schema.required_len()
274    };
275
276    schema
277        .fields
278        .iter()
279        .enumerate()
280        .map(|(i, field)| {
281            if i < read_len {
282                read_field(r, field.ty)
283            } else {
284                Ok(zero_field(field.ty))
285            }
286        })
287        .collect()
288}
289
290fn read_indexed8(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
291    let count = r.u8()?;
292    let mut result = IndexMap::new();
293
294    for _ in 0..count {
295        let id = r.u8()?;
296
297        if r.u8()? == 0 {
298            result.insert(id, None);
299            continue;
300        }
301
302        result.insert(id, Some(read_row(r, schema)?));
303    }
304
305    Ok(BlockData::Indexed8(result))
306}
307
308fn read_indexed32(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
309    let count = r.u16()?;
310    let mut result = IndexMap::new();
311
312    for _ in 0..count {
313        let id = r.u32()?;
314
315        if r.u8()? == 0 {
316            result.insert(id, None);
317            continue;
318        }
319
320        result.insert(id, Some(read_row(r, schema)?));
321    }
322
323    Ok(BlockData::Indexed32(result))
324}
325
326fn read_list16(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
327    let count = r.u16()?;
328    let mut result = Vec::with_capacity(count as usize);
329
330    for _ in 0..count {
331        result.push(read_row(r, schema)?);
332    }
333
334    Ok(BlockData::List16(result))
335}
336
337fn read_indexed_no_null8(r: &mut Reader, schema: &BlockSchema) -> Result<BlockData, UnpackError> {
338    let count = r.u8()?;
339    let mut result = IndexMap::new();
340
341    for _ in 0..count {
342        let index = r.u8()?;
343
344        result.insert(index, read_row(r, schema)?);
345    }
346
347    Ok(BlockData::IndexedNoNull8(result))
348}
349
350// ***** JSON-сериализация (формы, идентичные unpackFrame) ***** //
351
352/// round2-значение f32 → точный двухзначный f64 для JSON
353/// (JS readFloat возвращал round(f32·100)/100 в f64).
354pub fn round2_f64(v: f32) -> f64 {
355    ((v as f64) * 100.0).round() / 100.0
356}
357
358/// u32 → base36 (ключи бомб; JS id.toString(36)).
359pub fn to_base36(mut v: u32) -> String {
360    const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
361
362    if v == 0 {
363        return "0".to_string();
364    }
365
366    let mut out = Vec::new();
367
368    while v > 0 {
369        out.push(DIGITS[(v % 36) as usize]);
370        v /= 36;
371    }
372
373    out.reverse();
374    String::from_utf8(out).unwrap()
375}
376
377/// Камера в JS-форму: 0 | [x, y] | [x, y, true] | [x, y, ..., shake].
378pub fn camera_to_json(camera: Option<&DecodedCamera>) -> Value {
379    let Some(camera) = camera else {
380        return json!(0);
381    };
382
383    let mut arr = vec![
384        Value::from(round2_f64(camera.x)),
385        Value::from(round2_f64(camera.y)),
386    ];
387
388    if camera.force_reset {
389        arr.push(Value::from(true));
390    }
391
392    if let Some(shake) = &camera.shake {
393        // shake живёт в camera[3]; без forceReset слот [2] — null (как в JS)
394        while arr.len() < 3 {
395            arr.push(Value::Null);
396        }
397
398        arr.push(Value::from(shake.clone()));
399    }
400
401    Value::Array(arr)
402}
403
404fn field_value_to_json(value: FieldValue) -> Value {
405    match value {
406        FieldValue::F32(v) => Value::from(round2_f64(v)),
407        FieldValue::U8(v) => Value::from(v),
408        FieldValue::U16(v) => Value::from(v),
409        FieldValue::U32(v) => Value::from(v),
410    }
411}
412
413/// Строка блока в JS-форму: позиционный массив значений полей (порядок —
414/// `schema.fields`), как в JS-версии snapshotCodec.js.
415fn row_to_json(fields: &[FieldValue]) -> Value {
416    Value::Array(fields.iter().copied().map(field_value_to_json).collect())
417}
418
419/// Тело снапшота в JS-форму { ключ: данные } для parse-конвейера клиента.
420/// Форма контейнера по kind — не по игровой сущности: `Indexed8`/`Indexed32`
421/// дают объект id → строка|null, `List16` — массив строк, `IndexedNoNull8`
422/// — объект `d{index}` → строка (сохраняет исторический формат ключей
423/// динамических элементов карты).
424pub fn snapshot_to_json(snapshot: &DecodedSnapshot) -> Map<String, Value> {
425    let mut result = Map::new();
426
427    for block in &snapshot.blocks {
428        let value = match &block.data {
429            BlockData::Indexed8(items) => {
430                let mut map = Map::new();
431
432                for (id, row) in items {
433                    map.insert(
434                        id.to_string(),
435                        row.as_deref().map_or(Value::Null, row_to_json),
436                    );
437                }
438
439                Value::Object(map)
440            }
441            BlockData::Indexed32(items) => {
442                let mut map = Map::new();
443
444                for (id, row) in items {
445                    map.insert(
446                        to_base36(*id),
447                        row.as_deref().map_or(Value::Null, row_to_json),
448                    );
449                }
450
451                Value::Object(map)
452            }
453            BlockData::List16(items) => {
454                Value::Array(items.iter().map(|fields| row_to_json(fields)).collect())
455            }
456            BlockData::IndexedNoNull8(items) => {
457                let mut map = Map::new();
458
459                for (index, fields) in items {
460                    map.insert(format!("d{index}"), row_to_json(fields));
461                }
462
463                Value::Object(map)
464            }
465        };
466
467        result.insert(block.key.clone(), value);
468    }
469
470    result
471}
472
473/// Полный кадр в JSON-форму unpackFrame — для тестов и харнесса
474/// (ClientCore.decode_frame): { port, seq, serverTime, camera, player, snapshot }.
475pub fn frame_to_json(frame: &DecodedFrame) -> Value {
476    let player = frame.player.as_ref().map_or(Value::Null, |p| {
477        json!({
478            "gameId": p.game_id,
479            "inputSeq": p.input_seq,
480            // без округления: f64-расширение Float32 (как getFloat32 в JS)
481            "state": p.state.iter().map(|v| *v as f64).collect::<Vec<f64>>(),
482            "centering": p.centering,
483        })
484    });
485
486    json!({
487        "port": frame.port,
488        "seq": frame.seq,
489        "serverTime": frame.server_time,
490        "camera": camera_to_json(frame.camera.as_ref()),
491        "player": player,
492        "snapshot": Value::Object(snapshot_to_json(&frame.snapshot)),
493    })
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::config::test_support::full_snapshot_config;
500    use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
501
502    // round-trip против упаковщика того же crate: раскладка не может
503    // разойтись между pack и unpack по построению, тесты фиксируют формы
504    fn test_config() -> SnapshotConfig {
505        full_snapshot_config(3, 5)
506    }
507
508    fn packed_frame(
509        blocks: &[(String, Block)],
510        camera: Option<&CameraData>,
511        player: Option<&PlayerBlock>,
512    ) -> Vec<u8> {
513        let mut packer = SnapshotPacker::new(test_config());
514
515        packer.pack_body(blocks).unwrap();
516        packer.pack_frame(1234.5, 42, camera, player).to_vec()
517    }
518
519    fn tank_row(floats: [f32; 7], condition: u8, size: u8, team: u8) -> Vec<FieldValue> {
520        let mut fields: Vec<FieldValue> = floats.iter().copied().map(FieldValue::F32).collect();
521
522        fields.push(FieldValue::U8(condition));
523        fields.push(FieldValue::U8(size));
524        fields.push(FieldValue::U8(team));
525        fields
526    }
527
528    fn tracer_row(floats: [f32; 6], was_hit: bool, shooter: u8) -> Vec<FieldValue> {
529        let mut fields: Vec<FieldValue> = floats.iter().copied().map(FieldValue::F32).collect();
530
531        fields.push(FieldValue::U8(was_hit as u8));
532        fields.push(FieldValue::U8(shooter));
533        fields
534    }
535
536    fn bomb_row(x: f32, y: f32, angle: f32, size: u8, time: u16, owner: u8) -> Vec<FieldValue> {
537        vec![
538            FieldValue::F32(x),
539            FieldValue::F32(y),
540            FieldValue::F32(angle),
541            FieldValue::U8(size),
542            FieldValue::U16(time),
543            FieldValue::U8(owner),
544        ]
545    }
546
547    fn explosion_row(x: f32, y: f32, radius: f32) -> Vec<FieldValue> {
548        vec![FieldValue::F32(x), FieldValue::F32(y), FieldValue::F32(radius)]
549    }
550
551    fn field_f32(fields: &[FieldValue], i: usize) -> f32 {
552        match fields[i] {
553            FieldValue::F32(v) => v,
554            _ => panic!("поле {i} не F32"),
555        }
556    }
557
558    #[test]
559    fn full_frame_round_trip() {
560        let blocks = vec![
561            (
562                "m1".to_string(),
563                Block::Indexed8(vec![
564                    (
565                        2,
566                        Some(tank_row(
567                            [10.567, -3.141, 1.5, 0.25, 100.0, -50.5, 1.2],
568                            3,
569                            2,
570                            1,
571                        )),
572                    ),
573                    (3, None),
574                ]),
575            ),
576            (
577                "w1".to_string(),
578                Block::List16(vec![tracer_row(
579                    [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
580                    true,
581                    7,
582                )]),
583            ),
584            (
585                "w2".to_string(),
586                Block::Indexed32(vec![
587                    (
588                        // 'a1f' в base36
589                        u32::from_str_radix("a1f", 36).unwrap(),
590                        Some(bomb_row(5.5, 6.5, 0.0, 8, 300, 2)),
591                    ),
592                    (1, None),
593                ]),
594            ),
595            (
596                "w2e".to_string(),
597                Block::List16(vec![explosion_row(100.0, 200.0, 50.0)]),
598            ),
599            (
600                "c1".to_string(),
601                Block::IndexedNoNull8(vec![(0, explosion_row(10.0, 20.0, 0.5))]),
602            ),
603        ];
604
605        let camera = CameraData {
606            x: 10.5,
607            y: -3.25,
608            force_reset: true,
609            shake: Some("20:200".to_string()),
610        };
611        let player = PlayerBlock {
612            game_id: 2,
613            input_seq: 77,
614            state: [10.56789, 2.0, 0.5, 3.0, 4.0, 0.1, 0.2, 0.7],
615            centering: true,
616        };
617
618        let data = packed_frame(&blocks, Some(&camera), Some(&player));
619        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
620
621        assert_eq!(frame.port, 5);
622        assert_eq!(frame.seq, 42);
623        assert_eq!(frame.server_time, 1234.5);
624
625        // камера: координаты round2, флаги
626        let cam = frame.camera.as_ref().unwrap();
627
628        assert_eq!(cam.x, 10.5);
629        assert_eq!(cam.y, -3.25);
630        assert!(cam.force_reset);
631        assert_eq!(cam.shake.as_deref(), Some("20:200"));
632
633        // player-блок: без округления (точное f32-значение)
634        let p = frame.player.as_ref().unwrap();
635
636        assert_eq!(p.game_id, 2);
637        assert_eq!(p.input_seq, 77);
638        assert_eq!(p.state[0], 10.56789f32);
639        assert!(p.centering);
640
641        // танки: float снапшота восстановлены округлением до 2 знаков
642        let Some(BlockData::Indexed8(tanks)) = frame.snapshot.block_by_key("m1") else {
643            panic!("нет блока m1");
644        };
645        let row = tanks[&2].as_ref().unwrap();
646
647        assert_eq!(field_f32(row, 0), 10.57);
648        assert_eq!(field_f32(row, 1), -3.14);
649
650        match (row[7], row[8], row[9]) {
651            (FieldValue::U8(condition), FieldValue::U8(size), FieldValue::U8(team)) => {
652                assert_eq!((condition, size, team), (3, 2, 1));
653            }
654            _ => panic!("неожиданные типы полей tank"),
655        }
656        assert!(tanks[&3].is_none()); // null-маркер
657
658        let Some(BlockData::List16(tracers)) = frame.snapshot.block_by_key("w1") else {
659            panic!("нет блока w1");
660        };
661
662        match tracers[0][6] {
663            FieldValue::U8(v) => assert_eq!(v, 1),
664            _ => panic!("поле 6 не U8"),
665        }
666        match tracers[0][7] {
667            FieldValue::U8(v) => assert_eq!(v, 7),
668            _ => panic!("поле 7 не U8"),
669        }
670
671        let Some(BlockData::Indexed32(bombs)) = frame.snapshot.block_by_key("w2") else {
672            panic!("нет блока w2");
673        };
674        let bomb = bombs[&u32::from_str_radix("a1f", 36).unwrap()]
675            .as_ref()
676            .unwrap();
677
678        match (bomb[3], bomb[4], bomb[5]) {
679            (FieldValue::U8(size), FieldValue::U16(time), FieldValue::U8(owner)) => {
680                assert_eq!((size, time, owner), (8, 300, 2));
681            }
682            _ => panic!("неожиданные типы полей bomb"),
683        }
684        assert!(bombs[&1].is_none());
685
686        let Some(BlockData::List16(explosions)) = frame.snapshot.block_by_key("w2e") else {
687            panic!("нет блока w2e");
688        };
689
690        assert_eq!(
691            (
692                field_f32(&explosions[0], 0),
693                field_f32(&explosions[0], 1),
694                field_f32(&explosions[0], 2)
695            ),
696            (100.0, 200.0, 50.0)
697        );
698
699        let Some(BlockData::IndexedNoNull8(dynamics)) = frame.snapshot.block_by_key("c1") else {
700            panic!("нет блока c1");
701        };
702
703        let dyn_row = &dynamics[&0];
704
705        assert_eq!(
706            (
707                field_f32(dyn_row, 0),
708                field_f32(dyn_row, 1),
709                field_f32(dyn_row, 2)
710            ),
711            (10.0, 20.0, 0.5)
712        );
713
714        // строка пришла без опционального хвоста — наружу она всё равно
715        // полной ширины схемы, скорости нулевые
716        assert_eq!(dyn_row.len(), 6);
717        assert_eq!(
718            (
719                field_f32(dyn_row, 3),
720                field_f32(dyn_row, 4),
721                field_f32(dyn_row, 5)
722            ),
723            (0.0, 0.0, 0.0)
724        );
725    }
726
727    #[test]
728    fn dynamics_optional_tail_round_trip() {
729        let resting = vec![
730            FieldValue::F32(10.0),
731            FieldValue::F32(20.0),
732            FieldValue::F32(0.5),
733        ];
734        let moving = vec![
735            FieldValue::F32(30.0),
736            FieldValue::F32(40.0),
737            FieldValue::F32(1.5),
738            FieldValue::F32(-2.25),
739            FieldValue::F32(3.75),
740            FieldValue::F32(0.9),
741        ];
742
743        let data = packed_frame(
744            &[(
745                "c1".to_string(),
746                Block::IndexedNoNull8(vec![(0, resting), (1, moving)]),
747            )],
748            None,
749            None,
750        );
751        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
752
753        let Some(BlockData::IndexedNoNull8(dynamics)) = frame.snapshot.block_by_key("c1") else {
754            panic!("нет блока c1");
755        };
756
757        // покоящееся тело: хвоста в кадре нет, читается как нулевые скорости
758        assert_eq!(
759            (
760                field_f32(&dynamics[&0], 3),
761                field_f32(&dynamics[&0], 4),
762                field_f32(&dynamics[&0], 5)
763            ),
764            (0.0, 0.0, 0.0)
765        );
766
767        // движущееся: хвост дошёл значениями
768        assert_eq!(
769            (
770                field_f32(&dynamics[&1], 0),
771                field_f32(&dynamics[&1], 3),
772                field_f32(&dynamics[&1], 4),
773                field_f32(&dynamics[&1], 5)
774            ),
775            (30.0, -2.25, 3.75, 0.9)
776        );
777
778        // строка без хвоста дешевле ровно на 3 × f32
779        assert_eq!(data.len(), 15 + 1 + 1 + (2 + 12) + (2 + 12 + 12));
780    }
781
782    #[test]
783    fn wrong_version_is_rejected() {
784        let mut data = packed_frame(&[], None, None);
785
786        data[1] = 99;
787        assert!(matches!(
788            unpack_frame(&data, &test_config()),
789            Err(UnpackError::WrongVersion)
790        ));
791    }
792
793    #[test]
794    fn truncated_frame_is_error() {
795        let data = packed_frame(
796            &[(
797                "m1".to_string(),
798                Block::Indexed8(vec![(1, Some(tank_row([0.0; 7], 3, 2, 1)))]),
799            )],
800            None,
801            None,
802        );
803
804        assert!(matches!(
805            unpack_frame(&data[..data.len() - 4], &test_config()),
806            Err(UnpackError::Truncated)
807        ));
808    }
809
810    #[test]
811    fn unknown_block_id_drops_rest() {
812        let mut data = packed_frame(
813            &[(
814                "w2e".to_string(),
815                Block::List16(vec![explosion_row(1.0, 2.0, 3.0)]),
816            )],
817            None,
818            None,
819        );
820
821        // подмена id блока на незарегистрированный
822        data[15] = 200;
823
824        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
825
826        assert!(frame.snapshot.blocks.is_empty());
827    }
828
829    #[test]
830    fn base36_matches_js() {
831        assert_eq!(to_base36(0), "0");
832        assert_eq!(to_base36(35), "z");
833        assert_eq!(to_base36(u32::from_str_radix("a1f", 36).unwrap()), "a1f");
834    }
835
836    #[test]
837    fn camera_json_forms() {
838        assert_eq!(camera_to_json(None), json!(0));
839
840        let plain = DecodedCamera {
841            x: 1.5,
842            y: 2.5,
843            force_reset: false,
844            shake: None,
845        };
846
847        assert_eq!(camera_to_json(Some(&plain)), json!([1.5, 2.5]));
848
849        let reset = DecodedCamera {
850            force_reset: true,
851            ..plain.clone()
852        };
853
854        assert_eq!(camera_to_json(Some(&reset)), json!([1.5, 2.5, true]));
855
856        // shake без reset: слот [2] — null (как в JS-массиве с «дыркой»)
857        let shake = DecodedCamera {
858            shake: Some("20:200".to_string()),
859            ..plain
860        };
861
862        assert_eq!(
863            camera_to_json(Some(&shake)),
864            json!([1.5, 2.5, null, "20:200"])
865        );
866    }
867
868    #[test]
869    fn snapshot_json_matches_unpack_frame_forms() {
870        let blocks = vec![
871            (
872                "m1".to_string(),
873                Block::Indexed8(vec![
874                    (
875                        2,
876                        Some(tank_row([51.28, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], 3, 2, 1)),
877                    ),
878                    (3, None),
879                ]),
880            ),
881            (
882                "w2".to_string(),
883                Block::Indexed32(vec![(
884                    u32::from_str_radix("a1f", 36).unwrap(),
885                    Some(bomb_row(1.0, 2.0, 0.0, 8, 300, 2)),
886                )]),
887            ),
888        ];
889        let data = packed_frame(&blocks, None, None);
890        let frame = unpack_frame(&data, &test_config()).ok().unwrap();
891        let game = Value::Object(snapshot_to_json(&frame.snapshot));
892
893        // round2-значения сериализуются точными двухзначными числами
894        assert_eq!(
895            game,
896            json!({
897                "m1": { "2": [51.28, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 3, 2, 1], "3": null },
898                "w2": { "a1f": [1.0, 2.0, 0.0, 8, 300, 2] },
899            })
900        );
901    }
902}