1use serde_json::{Map, Value, json};
23
24use super::divergence::{DivergenceTracker, Observation, Source};
25use super::interpolator::{FrameData, InterpolatedGame, Interpolator};
26use super::unpack::{self, DecodedSnapshot, UnpackError};
27use crate::config::{BlockKind, EngineClientConfig, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
28
29pub struct RenderOverlay {
32 pub camera: [f32; 2],
33 pub tail: Vec<f32>,
34}
35
36pub struct PredictedRow {
43 pub key_id: u8,
44 pub id: u32,
45 pub fields: Vec<f32>,
46}
47
48pub trait GameClientDef: Sized {
53 type Config: serde::de::DeserializeOwned;
54
55 fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self;
56
57 fn on_server_state(
62 &mut self,
63 state: [f32; PLAYER_STATE_LEN],
64 centering: bool,
65 server_time: f64,
66 offset: f64,
67 local_now: f64,
68 );
69
70 fn update(&mut self, local_now: f64);
72
73 fn begin_reconcile(&mut self, _snapshot: &DecodedSnapshot) {}
78
79 fn finish_reconcile(&mut self) {}
83
84 fn render_rows(&self) -> Vec<PredictedRow> {
87 Vec::new()
88 }
89
90 fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData);
94
95 fn filter_frame_game(&mut self, game: &mut Map<String, Value>, my_game_id: Option<u32>, local_now: f64);
98
99 fn update_world(&mut self, snapshot: &DecodedSnapshot);
102
103 fn update_world_interpolated(&mut self, game: &InterpolatedGame);
105
106 fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay>;
110
111 fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
118 None
119 }
120
121 fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
126 None
127 }
128
129 fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64);
130
131 fn apply_aim(&mut self, _x: f32, _y: f32, _flags: u32, _local_now: f64) {}
135 fn set_model(&mut self, model_name: &str);
136 fn set_active(&mut self, active: bool);
137 fn set_map(&mut self, map_json: &str) -> Result<(), String>;
138 fn sync_panel(&mut self, items: &[String]);
139 fn reset(&mut self);
140
141 fn cycle_item(&mut self, back: bool);
144
145 fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String>;
148
149 fn dispatch_op(&mut self, _op: &str, _payload: &[u8]) -> Option<Vec<u8>> {
152 None
153 }
154
155 fn dispatch_ops(&self) -> &'static [&'static str] {
157 &[]
158 }
159}
160
161fn field_as_f32(value: FieldValue) -> f32 {
163 match value {
164 FieldValue::F32(v) => v,
165 FieldValue::U8(v) => v as f32,
166 FieldValue::U16(v) => v as f32,
167 FieldValue::U32(v) => v as f32,
168 }
169}
170
171fn blocks_of_kind<'a>(
174 snapshot_cfg: &'a SnapshotConfig,
175 game: &'a InterpolatedGame,
176 kind: BlockKind,
177) -> impl Iterator<Item = (u8, &'a Vec<super::interpolator::InterpolatedRow>)> {
178 game.blocks.iter().filter_map(move |(key, rows)| {
179 let schema = snapshot_cfg.keys.get(key)?;
180
181 (schema.kind == kind).then_some((schema.id, rows))
182 })
183}
184
185pub struct ClientState<G: GameClientDef> {
190 cfg: EngineClientConfig,
191 interpolator: Interpolator,
192 game: G,
193
194 my_game_id: Option<u32>,
196
197 frames_out: Vec<Value>,
199
200 hot: Vec<f32>,
202
203 divergence: Option<DivergenceTracker>,
206
207 row_widths: Box<[Option<u16>; 256]>,
211}
212
213impl<G: GameClientDef> ClientState<G> {
214 pub fn new(cfg: EngineClientConfig, game_cfg: &G::Config) -> Self {
215 let interpolator = Interpolator::new(&cfg.interpolation, cfg.snapshot.clone());
216 let game = G::new(game_cfg, &cfg);
217 let divergence = cfg.divergence.clone().map(DivergenceTracker::new);
218
219 let mut row_widths = Box::new([None; 256]);
220
221 for schema in cfg.snapshot.keys.values() {
222 row_widths[schema.id as usize] = Some(schema.fields.len() as u16);
223 }
224
225 Self {
226 cfg,
227 interpolator,
228 game,
229 my_game_id: None,
230 frames_out: Vec::new(),
231 hot: Vec::new(),
232 divergence,
233 row_widths,
234 }
235 }
236
237 pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
241 let frame = match unpack::unpack_frame(data, &self.cfg.snapshot) {
242 Ok(frame) => frame,
243 Err(UnpackError::WrongVersion | UnpackError::Truncated) => return false,
244 };
245
246 if frame.port != self.cfg.snapshot.port {
247 return false;
248 }
249
250 if frame.player.is_some() {
254 self.game.begin_reconcile(&frame.snapshot);
255 }
256
257 self.interpolator.push(
258 FrameData {
259 snapshot: frame.snapshot,
260 camera: frame.camera,
261 },
262 frame.server_time,
263 local_now,
264 frame.seq,
265 );
266
267 if let Some(player) = frame.player {
268 self.my_game_id = Some(player.game_id as u32);
269
270 let offset = self.interpolator.offset().unwrap_or(0.0);
272
273 self.observe_divergence(&player, frame.server_time, offset, local_now);
274
275 self.game.on_server_state(
276 player.state,
277 player.centering,
278 frame.server_time,
279 offset,
280 local_now,
281 );
282
283 self.game.finish_reconcile();
285 }
286
287 true
288 }
289
290 pub fn game(&self) -> &G {
293 &self.game
294 }
295
296 pub fn my_game_id(&self) -> Option<u32> {
297 self.my_game_id
298 }
299
300 pub fn offset(&self) -> Option<f64> {
301 self.interpolator.offset()
302 }
303
304 pub fn sample(&mut self, local_now: f64) -> usize {
308 let result = self.interpolator.sample(local_now);
309
310 for frame in result.frames {
312 self.game.track_frame(self.my_game_id, &frame);
313
314 let mut game = unpack::snapshot_to_json(&frame.snapshot);
315
316 self.game
317 .filter_frame_game(&mut game, self.my_game_id, local_now);
318
319 self.frames_out.push(json!({
320 "game": game,
321 "camera": unpack::camera_to_json(frame.camera.as_ref()),
322 }));
323
324 self.game.update_world(&frame.snapshot);
325 }
326
327 if let Some(game) = &result.game {
328 self.game.update_world_interpolated(game);
329 }
330
331 self.game.update(local_now);
332
333 let overlay = self.game.render_overlay(self.my_game_id);
334 let rows = self.game.render_rows();
335
336 self.write_hot(result.game.as_ref(), result.camera, overlay.as_ref(), &rows);
337 self.hot.len()
338 }
339
340 pub fn hot(&self) -> &[f32] {
341 &self.hot
342 }
343
344 pub fn take_frames(&mut self) -> String {
346 let frames = std::mem::take(&mut self.frames_out);
347
348 serde_json::to_string(&frames).unwrap_or_else(|_| "[]".to_string())
349 }
350
351 pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
352 self.game.apply_input(action, key_name, local_now);
353 }
354
355 pub fn apply_aim(&mut self, x: f32, y: f32, flags: u32, local_now: f64) {
356 self.game.apply_aim(x, y, flags, local_now);
357 }
358
359 pub fn try_action(&mut self, local_now: f64) -> Option<String> {
360 self.game.try_action(self.my_game_id, local_now)
361 }
362
363 pub fn cycle_item(&mut self, back: bool) {
364 self.game.cycle_item(back);
365 }
366
367 pub fn set_model(&mut self, model_name: &str) {
368 self.game.set_model(model_name);
369 }
370
371 pub fn set_active(&mut self, active: bool) {
373 self.game.set_active(active);
374 }
375
376 pub fn set_map(&mut self, map_json: &str) -> Result<(), String> {
378 self.interpolator.reset();
379 self.frames_out.clear();
380 self.game.set_map(map_json)
381 }
382
383 pub fn sync_panel(&mut self, panel_json: &str) {
384 let Ok(Value::Array(items)) = serde_json::from_str(panel_json) else {
385 return;
386 };
387
388 let items: Vec<String> = items
389 .iter()
390 .map(|item| match item {
391 Value::String(s) => s.clone(),
392 other => other.to_string(),
393 })
394 .collect();
395
396 self.game.sync_panel(&items);
397 }
398
399 pub fn reset(&mut self) {
401 self.interpolator.reset();
402 self.game.reset();
403 self.frames_out.clear();
404 self.my_game_id = None;
408 }
409
410 pub fn resync(&mut self) {
416 self.interpolator.reset();
417 self.frames_out.clear();
418 }
419
420 pub fn debug_json(&self) -> String {
424 json!({
425 "myGameId": self.my_game_id,
426 "offset": self.interpolator.offset(),
427 "interpolator": self.interpolator.debug_json(),
428 "hotLen": self.hot.len(),
429 "framesOut": self.frames_out.len(),
430 })
431 .to_string()
432 }
433
434 pub fn abi_describe(&self) -> String {
437 crate::abi::describe_json(crate::abi::ENGINE_CLIENT_OPS, self.game.dispatch_ops())
438 }
439
440 pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
444 let out = match op {
445 "debug.json" => Some(self.debug_json().into_bytes()),
446 _ => self.game.dispatch_op(op, payload),
447 };
448
449 crate::abi::dispatch_result(out)
450 }
451
452 pub fn take_divergence(&mut self) -> String {
456 match &mut self.divergence {
457 Some(tracker) => tracker.take_json(),
458 None => "null".to_string(),
459 }
460 }
461
462 fn observe_divergence(
465 &mut self,
466 player: &unpack::DecodedPlayer,
467 server_time: f64,
468 offset: f64,
469 local_now: f64,
470 ) {
471 let Some(tracker) = &mut self.divergence else {
472 return;
473 };
474
475 let (source, predicted) = match self.game.predicted_state() {
477 Some(state) => (Source::State, state.to_vec()),
478 None => match self.game.render_overlay(self.my_game_id) {
479 Some(overlay) => (Source::Camera, overlay.camera.to_vec()),
480 None => return,
481 },
482 };
483
484 tracker.observe(Observation {
485 source,
486 predicted: &predicted,
487 authoritative: &player.state,
488 server_time,
489 local_now,
490 offset,
491 input_seq: player.input_seq,
492 replayed: self.game.replayed_inputs(),
493 });
494 }
495
496 pub fn decode_frame(&self, data: &[u8]) -> String {
498 match unpack::unpack_frame(data, &self.cfg.snapshot) {
499 Ok(frame) => unpack::frame_to_json(&frame).to_string(),
500 Err(_) => "null".to_string(),
501 }
502 }
503
504 fn write_hot(
512 &mut self,
513 game: Option<&InterpolatedGame>,
514 camera: Option<[f32; 2]>,
515 overlay: Option<&RenderOverlay>,
516 rows: &[PredictedRow],
517 ) {
518 self.hot.clear();
519
520 let mut flags = 0u32;
521
522 if game.is_some() {
523 flags |= super::HOT_HAS_GAME;
524 }
525
526 if !self.frames_out.is_empty() {
527 flags |= super::HOT_HAS_FRAMES;
528 }
529
530 if overlay.is_some() || !rows.is_empty() {
535 flags |= super::HOT_HAS_PREDICTED;
536 }
537
538 let camera = overlay.map(|o| o.camera).or(camera);
540
541 if camera.is_some() {
542 flags |= super::HOT_HAS_CAMERA;
543 }
544
545 self.hot.push(flags as f32);
546
547 let camera = camera.unwrap_or([0.0, 0.0]);
548
549 self.hot.push(camera[0]);
550 self.hot.push(camera[1]);
551
552 let empty = InterpolatedGame::default();
553 let game = game.unwrap_or(&empty);
554
555 let tank_count: usize = blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8)
560 .map(|(_, rows)| rows.len())
561 .sum();
562
563 self.hot.push(tank_count as f32);
564
565 for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8) {
566 for row in rows {
567 self.hot.push(key_id as f32);
568 self.hot.push(row.id as f32);
569
570 for field in &row.fields {
571 self.hot.push(field_as_f32(*field));
572 }
573 }
574 }
575
576 let dynamic_count: usize =
577 blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8)
578 .map(|(_, rows)| rows.len())
579 .sum();
580
581 self.hot.push(dynamic_count as f32);
582
583 for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8) {
584 for row in rows {
585 self.hot.push(key_id as f32);
586 self.hot.push(row.id as f32);
587
588 for field in &row.fields {
589 self.hot.push(field_as_f32(*field));
590 }
591 }
592 }
593
594 if let Some(overlay) = overlay {
595 self.hot.extend_from_slice(&overlay.tail);
596 }
597
598 for row in rows {
599 let Some(width) = self.row_widths[row.key_id as usize] else {
603 continue;
604 };
605
606 let width = width as usize;
607
608 self.hot.push(row.key_id as f32);
609 self.hot.push(row.id as f32);
610
611 for index in 0..width {
612 self.hot.push(row.fields.get(index).copied().unwrap_or(0.0));
613 }
614 }
615 }
616}
617
618#[cfg(test)]
624mod fixture {
625 use super::*;
626 use serde::Deserialize;
627
628 #[derive(Deserialize)]
629 pub struct TestConfig {}
630
631 pub struct TestClient {
632 x: f32,
633 y: f32,
634 vx: f32,
635 vy: f32,
636 active: bool,
637 alive: bool,
638 last_update: Option<f64>,
639 report_state: bool,
642 predicted_rows: bool,
644 pub reconcile_log: Vec<&'static str>,
646 }
647
648 impl GameClientDef for TestClient {
649 type Config = TestConfig;
650
651 fn new(_cfg: &Self::Config, _engine_cfg: &EngineClientConfig) -> Self {
652 Self {
653 x: 0.0,
654 y: 0.0,
655 vx: 0.0,
656 vy: 0.0,
657 active: false,
658 alive: true,
659 last_update: None,
660 report_state: false,
661 predicted_rows: false,
662 reconcile_log: Vec::new(),
663 }
664 }
665
666 fn on_server_state(
667 &mut self,
668 state: [f32; PLAYER_STATE_LEN],
669 _centering: bool,
670 _server_time: f64,
671 _offset: f64,
672 _local_now: f64,
673 ) {
674 self.x = state[0];
675 self.y = state[1];
676 self.vx = state[3];
677 self.vy = state[4];
678 self.reconcile_log.push("state");
679 }
680
681 fn begin_reconcile(&mut self, _snapshot: &DecodedSnapshot) {
682 self.reconcile_log.push("begin");
683 }
684
685 fn finish_reconcile(&mut self) {
686 self.reconcile_log.push("finish");
687 }
688
689 fn update(&mut self, local_now: f64) {
690 let dt = self
691 .last_update
692 .map(|last| (local_now - last) / 1000.0)
693 .unwrap_or(0.0) as f32;
694
695 self.x += self.vx * dt;
696 self.y += self.vy * dt;
697 self.last_update = Some(local_now);
698 }
699
700 fn track_frame(&mut self, _my_game_id: Option<u32>, _frame: &FrameData) {}
701
702 fn filter_frame_game(
703 &mut self,
704 _game: &mut Map<String, Value>,
705 _my_game_id: Option<u32>,
706 _local_now: f64,
707 ) {
708 }
709
710 fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
711
712 fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
713
714 fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
715 let game_id = my_game_id?;
716
717 (self.active && self.alive).then(|| RenderOverlay {
718 camera: [self.x, self.y],
719 tail: vec![0.0, game_id as f32, self.x, self.y],
720 })
721 }
722
723 fn render_rows(&self) -> Vec<PredictedRow> {
728 if !self.predicted_rows {
729 return Vec::new();
730 }
731
732 vec![
733 PredictedRow {
734 key_id: 1,
735 id: 2,
736 fields: vec![111.0, 222.0],
737 },
738 PredictedRow {
739 key_id: 1,
740 id: 7,
741 fields: vec![333.0],
742 },
743 PredictedRow {
744 key_id: 200,
745 id: 9,
746 fields: vec![1.0, 2.0],
747 },
748 ]
749 }
750
751 fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
752 self.report_state
753 .then_some([self.x, self.y, 0.0, self.vx, self.vy, 0.0, 0.0, 0.0])
754 }
755
756 fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
757 self.last_update.map(|last| (last - 50.0, last, 2))
758 }
759
760 fn apply_input(&mut self, _action: &str, _key_name: &str, _local_now: f64) {}
761
762 fn set_model(&mut self, model_name: &str) {
763 self.report_state = model_name == "predicted";
764 self.predicted_rows = model_name == "rows";
765 }
766
767 fn set_active(&mut self, active: bool) {
768 self.active = active;
769 }
770
771 fn set_map(&mut self, _map_json: &str) -> Result<(), String> {
772 Ok(())
773 }
774
775 fn sync_panel(&mut self, _items: &[String]) {}
776
777 fn reset(&mut self) {
778 self.x = 0.0;
779 self.y = 0.0;
780 self.last_update = None;
781 }
782
783 fn cycle_item(&mut self, _back: bool) {}
784
785 fn try_action(&mut self, _my_game_id: Option<u32>, _local_now: f64) -> Option<String> {
786 None
787 }
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::fixture::{TestClient, TestConfig};
794 use super::*;
795 use crate::client::{HOT_HAS_CAMERA, HOT_HAS_FRAMES, HOT_HAS_GAME, HOT_HAS_PREDICTED};
796 use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
797
798 fn config_json() -> serde_json::Value {
799 serde_json::json!({
800 "timeStepMs": 1000.0 / 120.0,
801 "snapshot": {
802 "version": 3,
803 "port": 5,
804 "keys": {
805 "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
806 { "name": "x", "ty": "f32", "interp": "lerp" },
807 { "name": "y", "ty": "f32", "interp": "lerp" }
808 ] }
809 }
810 },
811 "interpolation": { "delay": 100, "maxFrameAge": 1000 }
812 })
813 }
814
815 fn engine_client_config() -> EngineClientConfig {
816 serde_json::from_value(config_json()).unwrap()
817 }
818
819 fn config_with_divergence(capacity: usize) -> EngineClientConfig {
822 let mut json = config_json();
823
824 json["divergence"] = serde_json::json!({
825 "defaultThreshold": 1.0,
826 "capacity": capacity,
827 });
828
829 serde_json::from_value(json).unwrap()
830 }
831
832 fn make_state() -> ClientState<TestClient> {
833 ClientState::new(engine_client_config(), &TestConfig {})
834 }
835
836 fn frame_bytes(server_time: f64, seq: u32, x: f32, with_player: bool) -> Vec<u8> {
837 let cfg = engine_client_config();
838 let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
839
840 packer
841 .pack_body(&[(
842 "actor".to_string(),
843 Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(x), FieldValue::F32(0.0)]))]),
844 )])
845 .unwrap();
846
847 let camera = CameraData {
848 x,
849 y: 0.0,
850 force_reset: false,
851 shake: None,
852 };
853 let player = PlayerBlock {
854 game_id: 2,
855 input_seq: 0,
856 state: [x, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
857 centering: false,
858 };
859
860 packer
861 .pack_frame(
862 server_time,
863 seq,
864 Some(&camera),
865 with_player.then_some(&player),
866 )
867 .to_vec()
868 }
869
870 #[test]
871 fn push_frame_and_sample_writes_hot_layout() {
872 let mut state = make_state();
873
874 state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
875 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
876
877 let len = state.sample(1150.0);
879 let hot = state.hot().to_vec();
880
881 assert_eq!(len, hot.len());
882
883 let flags = hot[0] as u32;
884
885 assert!(flags & HOT_HAS_GAME != 0);
886 assert!(flags & HOT_HAS_CAMERA != 0);
887 assert!(flags & HOT_HAS_FRAMES != 0);
888 assert!(flags & HOT_HAS_PREDICTED == 0);
889
890 assert_eq!(hot[3], 1.0);
892 assert_eq!(hot[4], 1.0);
893 assert_eq!(hot[5], 2.0);
894 assert_eq!(hot[6], 15.0);
895
896 let frames: Vec<serde_json::Value> =
897 serde_json::from_str(&state.take_frames()).unwrap();
898
899 assert_eq!(frames.len(), 1);
900 assert_eq!(state.take_frames(), "[]");
901 }
902
903 #[test]
904 fn render_overlay_appends_opaque_tail_and_sets_flag() {
905 let mut state = make_state();
906
907 state.set_active(true);
908 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
909
910 assert_eq!(state.my_game_id(), Some(2));
911
912 state.sample(1150.0);
913
914 let hot = state.hot().to_vec();
915 let flags = hot[0] as u32;
916
917 assert!(flags & HOT_HAS_PREDICTED != 0);
918
919 let tail = &hot[hot.len() - 4..];
921
922 assert_eq!(tail[1], 2.0); assert_eq!(hot[1], tail[2]); }
925
926 #[test]
927 fn reconcile_hooks_wrap_the_replay() {
928 let mut state = make_state();
929
930 state.set_active(true);
931
932 state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
934 assert!(state.game.reconcile_log.is_empty());
935
936 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
937
938 assert_eq!(state.game.reconcile_log, vec!["begin", "state", "finish"]);
941 }
942
943 #[test]
944 fn render_rows_follow_the_tail_and_keep_schema_width() {
945 let mut state = make_state();
946
947 state.set_active(true);
948 state.set_model("rows");
949 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
950 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
951 state.sample(1150.0);
952
953 let hot = state.hot().to_vec();
954
955 let rows = &hot[hot.len() - 8..];
958
959 assert_eq!(rows[0], 1.0); assert_eq!(rows[1], 2.0); assert_eq!(rows[2], 111.0);
962 assert_eq!(rows[3], 222.0);
963
964 assert_eq!(rows[4], 1.0);
966 assert_eq!(rows[5], 7.0);
967 assert_eq!(rows[6], 333.0);
968 assert_eq!(rows[7], 0.0);
969
970 assert_eq!(hot[5], 2.0);
973 assert_eq!(hot[6], 15.0);
974 }
975
976 #[test]
977 fn game_rows_alone_still_raise_the_tail_flag() {
978 let mut state = make_state();
984
985 state.set_active(true);
986 state.set_model("rows");
987 state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
988 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
989 state.sample(1150.0);
990
991 let hot = state.hot().to_vec();
992 let flags = hot[0] as u32;
993
994 assert!(flags & HOT_HAS_PREDICTED != 0);
995
996 let rows = &hot[hot.len() - 8..];
998
999 assert_eq!(rows[0], 1.0);
1000 assert_eq!(rows[1], 2.0);
1001 assert_eq!(rows[4], 1.0);
1002 assert_eq!(rows[5], 7.0);
1003 }
1004
1005 #[test]
1006 fn render_rows_default_to_empty() {
1007 let mut state = make_state();
1008
1009 state.set_active(true);
1010 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1011 state.sample(1150.0);
1012
1013 let hot = state.hot().to_vec();
1015
1016 assert_eq!(hot.len(), 3 + 1 + 4 + 1 + 4);
1017 }
1018
1019 #[test]
1020 fn reset_clears_predictor_and_frame_queue() {
1021 let mut state = make_state();
1022
1023 state.set_active(true);
1024 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1025 state.sample(1150.0);
1026
1027 state.reset();
1028
1029 assert_eq!(state.take_frames(), "[]");
1030 }
1031
1032 #[test]
1033 fn resync_clears_network_half_only() {
1034 let mut state = make_state();
1035
1036 state.set_active(true);
1037 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1038 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
1039 state.sample(1150.0);
1040
1041 state.resync();
1042
1043 let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
1044
1045 assert_eq!(dump["interpolator"]["buffered"], 0);
1046 assert!(dump["interpolator"]["lastFrame"].is_null());
1047 assert!(dump["offset"].is_null());
1048 assert_eq!(dump["framesOut"], 0);
1049 assert_eq!(state.take_frames(), "[]");
1050
1051 assert_eq!(state.my_game_id(), Some(2));
1053 }
1054
1055 #[test]
1056 fn debug_json_reports_buffer_seq_window_and_offset() {
1057 let mut state = make_state();
1058
1059 let empty: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
1060
1061 assert!(empty["myGameId"].is_null());
1062 assert_eq!(empty["interpolator"]["buffered"], 0);
1063 assert!(empty["interpolator"]["lastFrame"].is_null());
1064
1065 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1066 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
1067 state.sample(1150.0);
1068
1069 let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
1070
1071 assert_eq!(dump["myGameId"], 2);
1072 assert_eq!(dump["interpolator"]["buffered"], 2);
1073 assert_eq!(dump["interpolator"]["seqWindow"], serde_json::json!([1, 2]));
1074 assert_eq!(dump["interpolator"]["lastFrame"]["seq"], 2);
1075 assert_eq!(dump["interpolator"]["lastFrame"]["serverTime"], 1100.0);
1076 assert_eq!(dump["interpolator"]["delay"], 100.0);
1077 assert_eq!(dump["offset"], dump["interpolator"]["offset"]);
1078 assert_eq!(dump["hotLen"], state.hot().len());
1079 assert_eq!(dump["framesOut"], 1);
1080 }
1081
1082 #[test]
1085 fn divergence_is_off_without_config() {
1086 let mut state = make_state();
1087
1088 state.set_active(true);
1089 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1090
1091 assert_eq!(state.take_divergence(), "null");
1092 }
1093
1094 #[test]
1097 fn divergence_falls_back_to_overlay_camera() {
1098 let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
1099
1100 state.set_active(true);
1101 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1102
1103 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1104 let record = &dump["records"][0];
1105
1106 assert_eq!(dump["samples"], 1);
1107 assert_eq!(dump["violations"], 1);
1108 assert_eq!(record["source"], "camera");
1109 assert_eq!(record["predicted"], serde_json::json!([0.0, 0.0]));
1110 assert_eq!(record["authoritative"], serde_json::json!([10.0, 0.0]));
1111 assert_eq!(record["delta"][0], -10.0);
1112 assert_eq!(record["exceeded"], serde_json::json!([0]));
1113 assert_eq!(record["thresholds"][0], 1.0);
1114 assert!(record["replayed"].is_null());
1115 }
1116
1117 #[test]
1121 fn divergence_reports_predicted_state_and_replay_window() {
1122 let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
1123
1124 state.set_active(true);
1125 state.set_model("predicted");
1126
1127 state.push_frame(&frame_bytes(1000.0, 1, 0.0, true), 1000.0);
1129 state.sample(1100.0);
1130 state.push_frame(&frame_bytes(1100.0, 2, 50.0, true), 1100.0);
1131
1132 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1133
1134 assert_eq!(dump["samples"], 2);
1135 assert_eq!(dump["violations"], 1);
1136 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1137 assert_eq!(dump["maxDelta"][0], 50.0);
1138
1139 let record = &dump["records"][0];
1140
1141 assert_eq!(record["source"], "state");
1142 assert_eq!(record["serverTime"], 1100.0);
1143 assert_eq!(record["localNow"], 1100.0);
1144 assert_eq!(record["inputSeq"], 0);
1145 assert_eq!(record["delta"][0], -50.0);
1146 assert_eq!(record["exceeded"], serde_json::json!([0]));
1147 assert_eq!(record["replayed"]["from"], 1050.0);
1148 assert_eq!(record["replayed"]["to"], 1100.0);
1149 assert_eq!(record["replayed"]["count"], 2);
1150
1151 let drained: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1153
1154 assert_eq!(drained["records"].as_array().unwrap().len(), 0);
1155 assert_eq!(drained["samples"], 2);
1156 assert_eq!(drained["maxDelta"][0], 50.0);
1157 }
1158
1159 #[test]
1160 fn divergence_ring_buffer_evicts_oldest_records() {
1161 let mut state = ClientState::<TestClient>::new(config_with_divergence(1), &TestConfig {});
1162
1163 state.set_active(true);
1164 state.set_model("predicted");
1165 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1166 state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
1167
1168 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1169
1170 assert_eq!(dump["violations"], 2);
1171 assert_eq!(dump["dropped"], 1);
1172 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1173 assert_eq!(dump["records"][0]["serverTime"], 1100.0);
1174 }
1175
1176 #[test]
1179 fn divergence_zero_capacity_counts_evictions_honestly() {
1180 let mut state = ClientState::<TestClient>::new(config_with_divergence(0), &TestConfig {});
1181
1182 state.set_active(true);
1183 state.set_model("predicted");
1184 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1185 state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
1186
1187 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1188
1189 assert_eq!(dump["violations"], 2);
1190 assert_eq!(dump["dropped"], 1);
1191 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1192 }
1193
1194 #[test]
1200 fn second_schema_key_of_different_block_kind_flows_into_hot_buffer() {
1201 let config = serde_json::json!({
1202 "timeStepMs": 1000.0 / 120.0,
1203 "snapshot": {
1204 "version": 3,
1205 "port": 5,
1206 "keys": {
1207 "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
1208 { "name": "x", "ty": "f32", "interp": "lerp" },
1209 { "name": "y", "ty": "f32", "interp": "lerp" }
1210 ] },
1211 "zone": { "id": 2, "kind": "indexedNoNull8", "class": "hot", "fields": [
1212 { "name": "level", "ty": "f32", "interp": "discrete" }
1213 ] }
1214 }
1215 },
1216 "interpolation": { "delay": 100, "maxFrameAge": 1000 }
1217 });
1218 let cfg: EngineClientConfig = serde_json::from_value(config).unwrap();
1219 let mut state = ClientState::<TestClient>::new(cfg.clone(), &TestConfig {});
1220 let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
1221
1222 packer
1223 .pack_body(&[
1224 (
1225 "actor".to_string(),
1226 Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(10.0), FieldValue::F32(0.0)]))]),
1227 ),
1228 (
1229 "zone".to_string(),
1230 Block::IndexedNoNull8(vec![(0, vec![FieldValue::F32(7.0)])]),
1231 ),
1232 ])
1233 .unwrap();
1234
1235 let frame = packer.pack_frame(1000.0, 1, None, None).to_vec();
1236
1237 state.push_frame(&frame, 1000.0);
1238 state.push_frame(&frame, 1100.0);
1239 state.sample(1150.0);
1240
1241 let hot = state.hot().to_vec();
1242
1243 assert_eq!(hot[3], 1.0); assert_eq!(hot[8], 1.0); assert_eq!(hot[9], 2.0); assert_eq!(hot[11], 7.0); }
1249
1250 #[test]
1251 fn reset_clears_my_game_id() {
1252 let mut state = make_state();
1253
1254 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1255 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
1256 state.sample(1150.0);
1257 assert_eq!(state.my_game_id(), Some(2));
1258
1259 state.reset();
1262 assert_eq!(state.my_game_id(), None);
1263 }
1264}