1use std::sync::atomic::{AtomicU64, Ordering};
27
28use chrono::{DateTime, Utc};
29
30use crate::events::GameEvent;
31use crate::log::entry::LogEntry;
32use crate::log::timestamp::parse_log_timestamp;
33use crate::parsers;
34use crate::util::truncate_for_log;
35
36#[derive(Debug, Default)]
47pub struct RouterStats {
48 routed: AtomicU64,
50 unknown: AtomicU64,
52 timestamp_failures: AtomicU64,
54}
55
56impl RouterStats {
57 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn routed_count(&self) -> u64 {
64 self.routed.load(Ordering::Relaxed)
65 }
66
67 pub fn unknown_count(&self) -> u64 {
69 self.unknown.load(Ordering::Relaxed)
70 }
71
72 pub fn timestamp_failure_count(&self) -> u64 {
74 self.timestamp_failures.load(Ordering::Relaxed)
75 }
76
77 pub fn reset(&self) {
79 self.routed.store(0, Ordering::Relaxed);
80 self.unknown.store(0, Ordering::Relaxed);
81 self.timestamp_failures.store(0, Ordering::Relaxed);
82 }
83}
84
85pub struct Router {
113 stats: RouterStats,
115}
116
117impl Router {
118 pub fn new() -> Self {
120 Self {
121 stats: RouterStats::new(),
122 }
123 }
124
125 pub fn stats(&self) -> &RouterStats {
127 &self.stats
128 }
129
130 pub fn route(&self, entry: &LogEntry) -> Vec<GameEvent> {
145 let timestamp = extract_timestamp(&entry.body);
146
147 if timestamp.is_none() {
148 self.stats
149 .timestamp_failures
150 .fetch_add(1, Ordering::Relaxed);
151 ::log::debug!(
152 "No timestamp in entry header: {:?}",
153 truncate_for_log(&entry.body, 120),
154 );
155 }
156
157 let events = dispatch_to_parsers(entry, timestamp);
158
159 if events.is_empty() {
160 self.stats.unknown.fetch_add(1, Ordering::Relaxed);
161 ::log::debug!(
162 "Unrecognized entry (header={}, body={:?})",
163 entry.header,
164 truncate_for_log(&entry.body, 120),
165 );
166 } else {
167 self.stats.routed.fetch_add(1, Ordering::Relaxed);
168 }
169
170 events
171 }
172}
173
174impl Default for Router {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180fn extract_timestamp(body: &str) -> Option<DateTime<Utc>> {
201 let first_line = body.lines().next()?;
202
203 let after_bracket = first_line.find(']').map(|pos| &first_line[pos + 1..])?;
205 let trimmed = after_bracket.trim();
206
207 if trimmed.is_empty() {
208 return None;
209 }
210
211 let words: Vec<&str> = trimmed.split_whitespace().collect();
215
216 let max_words = words.len().min(4);
219 for end in (2..=max_words).rev() {
220 let candidate = words[..end].join(" ");
221 let cleaned = candidate.trim_end_matches(|c: char| c.is_ascii_punctuation());
223 if let Ok(ts) = parse_log_timestamp(cleaned) {
224 return Some(ts);
225 }
226 }
227
228 None
229}
230
231fn dispatch_to_parsers(entry: &LogEntry, timestamp: Option<DateTime<Utc>>) -> Vec<GameEvent> {
257 if let Some(event) = parsers::metadata::try_parse(entry, timestamp) {
259 return vec![event];
260 }
261
262 let gre_events = parsers::gre::try_parse(entry, timestamp);
264 if !gre_events.is_empty() {
265 return gre_events;
266 }
267
268 let event = None
270 .or_else(|| parsers::client_actions::try_parse(entry, timestamp))
271 .or_else(|| parsers::match_state::try_parse(entry, timestamp))
272 .or_else(|| parsers::session::try_parse(entry, timestamp))
273 .or_else(|| parsers::draft::bot::try_parse(entry, timestamp))
274 .or_else(|| parsers::draft::human::try_parse(entry, timestamp))
275 .or_else(|| parsers::draft::complete::try_parse(entry, timestamp))
276 .or_else(|| parsers::event_lifecycle::try_parse(entry, timestamp))
277 .or_else(|| parsers::rank::try_parse(entry, timestamp))
278 .or_else(|| parsers::inventory::try_parse(entry, timestamp))
279 .or_else(|| parsers::connection_state::try_parse(entry, timestamp))
280 .or_else(|| parsers::connection_close::try_parse(entry, timestamp))
281 .or_else(|| parsers::connection_error::try_parse(entry, timestamp));
282
283 event.into_iter().collect()
284}
285
286#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::log::entry::EntryHeader;
294 use chrono::Timelike;
295
296 fn unity_entry(body: &str) -> LogEntry {
298 LogEntry {
299 header: EntryHeader::UnityCrossThreadLogger,
300 body: body.to_owned(),
301 }
302 }
303
304 fn gre_entry(body: &str) -> LogEntry {
306 LogEntry {
307 header: EntryHeader::ClientGre,
308 body: body.to_owned(),
309 }
310 }
311
312 mod extract_timestamp_tests {
315 use super::*;
316
317 #[test]
318 fn test_extract_timestamp_us_format_with_pm() {
319 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent";
320 let ts = extract_timestamp(body);
321 assert!(ts.is_some());
322 if let Some(ts) = ts {
323 assert_eq!(
324 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
325 "2026-02-25 12:00:00"
326 );
327 }
328 }
329
330 #[test]
331 fn test_extract_timestamp_us_format_with_am() {
332 let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM";
333 let ts = extract_timestamp(body);
334 assert!(ts.is_some());
335 if let Some(ts) = ts {
336 assert_eq!(
337 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
338 "2026-02-22 11:59:51"
339 );
340 }
341 }
342
343 #[test]
344 fn test_extract_timestamp_with_trailing_colon() {
345 let body = "[UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to AAF4FC69CE47D53A";
346 let ts = extract_timestamp(body);
347 assert!(ts.is_some());
348 if let Some(ts) = ts {
349 assert_eq!(ts.hour(), 23); }
351 }
352
353 #[test]
354 fn test_extract_timestamp_24h_format() {
355 let body = "[UnityCrossThreadLogger]2026-02-25 14:30:00 some content";
356 let ts = extract_timestamp(body);
357 assert!(ts.is_some());
358 if let Some(ts) = ts {
359 assert_eq!(
360 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
361 "2026-02-25 14:30:00"
362 );
363 }
364 }
365
366 #[test]
367 fn test_extract_timestamp_client_gre_header() {
368 let body = "[Client GRE]2/25/2026 12:00:00 PM GreToClientEvent";
369 let ts = extract_timestamp(body);
370 assert!(ts.is_some());
371 }
372
373 #[test]
374 fn test_extract_timestamp_no_bracket_returns_none() {
375 let body = "no bracket here";
376 let ts = extract_timestamp(body);
377 assert!(ts.is_none());
378 }
379
380 #[test]
381 fn test_extract_timestamp_empty_after_bracket_returns_none() {
382 let body = "[UnityCrossThreadLogger]";
383 let ts = extract_timestamp(body);
384 assert!(ts.is_none());
385 }
386
387 #[test]
388 fn test_extract_timestamp_no_timestamp_content_returns_none() {
389 let body = "[UnityCrossThreadLogger]Updated account. DisplayName:Player";
390 let ts = extract_timestamp(body);
391 assert!(ts.is_none());
392 }
393
394 #[test]
395 fn test_extract_timestamp_timestamp_on_own_line() {
396 let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n<== StartHook(abc-123)";
397 let ts = extract_timestamp(body);
398 assert!(ts.is_some());
399 if let Some(ts) = ts {
400 assert_eq!(
401 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
402 "2026-02-22 11:59:51"
403 );
404 }
405 }
406
407 #[test]
408 fn test_extract_timestamp_with_leading_space() {
409 let body = "[UnityCrossThreadLogger] 2/25/2026 12:00:00 PM event";
410 let ts = extract_timestamp(body);
411 assert!(ts.is_some());
412 }
413 }
414
415 mod known_routing {
418 use super::*;
419
420 #[test]
421 fn test_route_gre_game_state_message() {
422 let router = Router::new();
423 let payload = serde_json::json!({
424 "greToClientEvent": {
425 "greToClientMessages": [{
426 "type": "GREMessageType_GameStateMessage",
427 "gameStateMessage": {
428 "gameInfo": { "stage": "GameStage_Play" },
429 "gameObjects": [],
430 "zones": []
431 }
432 }]
433 }
434 });
435 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
436 let entry = unity_entry(&body);
437
438 let results = router.route(&entry);
439 assert_eq!(results.len(), 1);
440 assert!(matches!(&results[0], GameEvent::GameState(_)));
441 assert_eq!(router.stats().routed_count(), 1);
442 assert_eq!(router.stats().unknown_count(), 0);
443 }
444
445 #[test]
446 fn test_route_client_action() {
447 let router = Router::new();
448 let payload = serde_json::json!({
449 "clientToMatchServiceMessageType":
450 "ClientToMatchServiceMessageType_ClientToGREMessage",
451 "payload": {
452 "type": "ClientMessageType_MulliganResp",
453 "gameStateId": 5,
454 "respId": 1,
455 "mulliganResp": { "decision": "MulliganOption_Mulligan" }
456 },
457 "requestId": 12345,
458 "timestamp": "637123456789"
459 });
460 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
461 let entry = unity_entry(&body);
462
463 let results = router.route(&entry);
464 assert_eq!(results.len(), 1);
465 assert!(matches!(&results[0], GameEvent::ClientAction(_)));
466 }
467
468 #[test]
469 fn test_route_match_state() {
470 let router = Router::new();
471 let payload = serde_json::json!({
472 "matchGameRoomStateChangedEvent": {
473 "gameRoomInfo": {
474 "stateType": "MatchGameRoomStateType_Playing",
475 "gameRoomConfig": {
476 "matchId": "match-123",
477 "reservedPlayers": []
478 }
479 }
480 }
481 });
482 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
483 let entry = unity_entry(&body);
484
485 let results = router.route(&entry);
486 assert_eq!(results.len(), 1);
487 assert!(matches!(&results[0], GameEvent::MatchState(_)));
488 }
489
490 #[test]
491 fn test_route_session_account_update() {
492 let router = Router::new();
493 let body = "[UnityCrossThreadLogger]Updated account. \
494 DisplayName:TestPlayer, \
495 AccountID:abc123, \
496 Token:sometoken";
497 let entry = unity_entry(body);
498
499 let results = router.route(&entry);
500 assert_eq!(results.len(), 1);
501 assert!(matches!(&results[0], GameEvent::Session(_)));
502 }
503
504 #[test]
505 fn test_route_rank_event() {
506 let router = Router::new();
507 let payload = serde_json::json!({
508 "constructedClass": "Gold",
509 "constructedLevel": 2,
510 "limitedClass": "Silver",
511 "limitedLevel": 1
512 });
513 let body = format!(
514 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
515 <== RankGetCombinedRankInfo(abc-123)\n{payload}",
516 );
517 let entry = unity_entry(&body);
518
519 let results = router.route(&entry);
520 assert_eq!(results.len(), 1);
521 assert!(matches!(&results[0], GameEvent::Rank(_)));
522 }
523
524 #[test]
525 fn test_route_event_lifecycle() {
526 let router = Router::new();
527 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
528 ==> EventJoin {\"id\":\"abc-123\",\
529 \"request\":\"{\\\"EventName\\\":\\\"PremierDraft_MKM\\\"}\"}";
530 let entry = unity_entry(body);
531
532 let results = router.route(&entry);
533 assert_eq!(results.len(), 1);
534 assert!(matches!(&results[0], GameEvent::EventLifecycle(_)));
535 }
536
537 #[test]
538 fn test_route_draft_complete() {
539 let router = Router::new();
540 let payload = serde_json::json!({
541 "CourseId": "draft-123",
542 "InternalEventName": "PremierDraft_MKM",
543 "CardPool": [12345, 67890]
544 });
545 let body = format!(
546 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
547 <== DraftCompleteDraft(abc-123)\n{payload}",
548 );
549 let entry = unity_entry(&body);
550
551 let results = router.route(&entry);
552 assert_eq!(results.len(), 1);
553 assert!(matches!(&results[0], GameEvent::DraftComplete(_)));
554 }
555
556 #[test]
557 fn test_route_draft_bot_pack_presentation() {
558 let router = Router::new();
559 let payload = serde_json::json!({
560 "DraftStatus": "PickNext",
561 "PackNumber": 0,
562 "PickNumber": 0,
563 "DraftPack": ["12345", "67890", "11111"],
564 "EventName": "QuickDraft_MKM_20260201"
565 });
566 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}",);
567 let entry = unity_entry(&body);
568
569 let results = router.route(&entry);
570 assert_eq!(results.len(), 1);
571 assert!(matches!(&results[0], GameEvent::DraftBot(_)));
572 }
573
574 #[test]
575 fn test_route_draft_human_notify() {
576 let router = Router::new();
577 let payload = serde_json::json!({
578 "draftId": "abc-123-def",
579 "SelfPack": 0,
580 "SelfPick": 0,
581 "PackCards": "12345,67890,11111"
582 });
583 let body = format!("[UnityCrossThreadLogger]Draft.Notify\n{payload}",);
584 let entry = unity_entry(&body);
585
586 let results = router.route(&entry);
587 assert_eq!(results.len(), 1);
588 assert!(matches!(&results[0], GameEvent::DraftHuman(_)));
589 }
590
591 #[test]
592 fn test_route_start_hook_with_additional_fields_routes_to_inventory() {
593 let router = Router::new();
594 let payload = serde_json::json!({
595 "InventoryInfo": { "Gems": 100 },
596 "DeckSummariesV2": []
597 });
598 let body = format!(
599 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
600 <== StartHook(abc-123)\n{payload}",
601 );
602 let entry = unity_entry(&body);
603
604 let results = router.route(&entry);
605 assert_eq!(results.len(), 1);
606 assert!(matches!(&results[0], GameEvent::Inventory(_)));
607 }
608
609 #[test]
610 fn test_route_inventory_event() {
611 let router = Router::new();
612 let payload = serde_json::json!({
613 "InventoryInfo": { "Gems": 100, "Gold": 5000 }
614 });
615 let body = format!(
616 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
617 <== StartHook(abc-123)\n{payload}",
618 );
619 let entry = unity_entry(&body);
620
621 let results = router.route(&entry);
622 assert_eq!(results.len(), 1);
623 assert!(matches!(&results[0], GameEvent::Inventory(_)));
624 }
625 }
626
627 mod unknown_entries {
630 use super::*;
631
632 #[test]
633 fn test_route_unknown_entry_returns_empty() {
634 let router = Router::new();
635 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
636 some unrecognized content here";
637 let entry = unity_entry(body);
638
639 let results = router.route(&entry);
640 assert!(results.is_empty());
641 }
642
643 #[test]
644 fn test_route_unknown_entry_increments_counter() {
645 let router = Router::new();
646 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
647 unrecognized content";
648 let entry = unity_entry(body);
649
650 router.route(&entry);
651 assert_eq!(router.stats().unknown_count(), 1);
652 assert_eq!(router.stats().routed_count(), 0);
653 }
654
655 #[test]
656 fn test_route_multiple_unknown_entries_accumulates() {
657 let router = Router::new();
658
659 for i in 0..5 {
660 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown_{i}",);
661 let entry = unity_entry(&body);
662 router.route(&entry);
663 }
664
665 assert_eq!(router.stats().unknown_count(), 5);
666 assert_eq!(router.stats().routed_count(), 0);
667 }
668
669 #[test]
670 fn test_route_empty_body_after_header_returns_empty() {
671 let router = Router::new();
672 let body = "[UnityCrossThreadLogger]";
673 let entry = unity_entry(body);
674
675 let results = router.route(&entry);
676 assert!(results.is_empty());
678 assert_eq!(router.stats().timestamp_failure_count(), 1);
679 assert_eq!(router.stats().unknown_count(), 1);
680 }
681
682 #[test]
683 fn test_route_no_timestamp_increments_timestamp_failure() {
684 let router = Router::new();
685 let body = "[UnityCrossThreadLogger]just some text without a timestamp";
686 let entry = unity_entry(body);
687
688 let results = router.route(&entry);
689 assert!(results.is_empty());
691 assert_eq!(router.stats().timestamp_failure_count(), 1);
692 assert_eq!(router.stats().unknown_count(), 1);
693 }
694
695 #[test]
696 fn test_route_no_timestamp_session_still_routes() {
697 let router = Router::new();
698 let body = "[UnityCrossThreadLogger]Updated account. \
700 DisplayName:Player, \
701 AccountID:abc123, \
702 Token:token";
703 let entry = unity_entry(body);
704
705 let results = router.route(&entry);
706 assert_eq!(results.len(), 1);
707 assert!(matches!(&results[0], GameEvent::Session(_)));
709 assert_eq!(router.stats().timestamp_failure_count(), 1);
710 assert_eq!(router.stats().routed_count(), 1);
711 }
712
713 #[test]
714 fn test_route_no_timestamp_passes_none_to_metadata() {
715 let router = Router::new();
716 let body = "[UnityCrossThreadLogger]Updated account. \
719 DisplayName:Player, \
720 AccountID:abc123, \
721 Token:token";
722 let entry = unity_entry(body);
723
724 let results = router.route(&entry);
725 assert_eq!(results.len(), 1);
726 assert!(
727 results[0].metadata().timestamp().is_none(),
728 "entries without parseable timestamps should have None timestamp"
729 );
730 }
731
732 #[test]
733 fn test_route_with_timestamp_passes_some_to_metadata() {
734 let router = Router::new();
735 let payload = serde_json::json!({
736 "greToClientEvent": {
737 "greToClientMessages": [{
738 "type": "GREMessageType_GameStateMessage",
739 "gameStateMessage": {
740 "gameInfo": { "stage": "GameStage_Play" },
741 "gameObjects": [],
742 "zones": []
743 }
744 }]
745 }
746 });
747 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
748 let entry = unity_entry(&body);
749
750 let results = router.route(&entry);
751 assert_eq!(results.len(), 1);
752 assert!(
753 results[0].metadata().timestamp().is_some(),
754 "entries with parseable timestamps should have Some timestamp"
755 );
756 }
757 }
758
759 mod stats {
762 use super::*;
763
764 #[test]
765 fn test_stats_initial_values_are_zero() {
766 let router = Router::new();
767 assert_eq!(router.stats().routed_count(), 0);
768 assert_eq!(router.stats().unknown_count(), 0);
769 assert_eq!(router.stats().timestamp_failure_count(), 0);
770 }
771
772 #[test]
773 fn test_stats_reset_clears_all_counters() {
774 let router = Router::new();
775
776 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
778 let entry = unity_entry(body);
779 router.route(&entry);
780 router.route(&entry);
781
782 assert_eq!(router.stats().unknown_count(), 2);
783
784 router.stats().reset();
785
786 assert_eq!(router.stats().routed_count(), 0);
787 assert_eq!(router.stats().unknown_count(), 0);
788 assert_eq!(router.stats().timestamp_failure_count(), 0);
789 }
790
791 #[test]
792 fn test_stats_mixed_routing() {
793 let router = Router::new();
794
795 let known_body = "[UnityCrossThreadLogger]Updated account. \
797 DisplayName:Player, \
798 AccountID:abc123, \
799 Token:token";
800 router.route(&unity_entry(known_body));
801
802 let unknown_body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
804 router.route(&unity_entry(unknown_body));
805
806 let bad_ts_body = "[UnityCrossThreadLogger]";
808 router.route(&unity_entry(bad_ts_body));
809
810 assert_eq!(router.stats().routed_count(), 1);
811 assert_eq!(router.stats().unknown_count(), 2);
813 assert_eq!(router.stats().timestamp_failure_count(), 2);
815 }
816 }
817
818 mod default_impl {
821 use super::*;
822
823 #[test]
824 fn test_router_default_creates_functional_router() {
825 let router = Router::default();
826 assert_eq!(router.stats().routed_count(), 0);
827 assert_eq!(router.stats().unknown_count(), 0);
828 }
829 }
830
831 mod client_gre_entries {
834 use super::*;
835
836 #[test]
837 fn test_route_client_gre_entry() {
838 let router = Router::new();
839 let payload = serde_json::json!({
840 "greToClientEvent": {
841 "greToClientMessages": [{
842 "type": "GREMessageType_GameStateMessage",
843 "gameStateMessage": {
844 "gameInfo": { "stage": "GameStage_Play" },
845 "gameObjects": [],
846 "zones": []
847 }
848 }]
849 }
850 });
851 let body = format!("[Client GRE]2/25/2026 12:00:00 PM\n{payload}");
852 let entry = gre_entry(&body);
853
854 let results = router.route(&entry);
855 assert_eq!(results.len(), 1);
856 assert!(matches!(&results[0], GameEvent::GameState(_)));
857 }
858 }
859
860 mod metadata_entries {
863 use super::*;
864
865 fn metadata_entry(body: &str) -> LogEntry {
867 LogEntry {
868 header: EntryHeader::Metadata,
869 body: body.to_owned(),
870 }
871 }
872
873 #[test]
874 fn test_route_detailed_logs_enabled() {
875 let router = Router::new();
876 let entry = metadata_entry("DETAILED LOGS: ENABLED");
877
878 let results = router.route(&entry);
879 assert_eq!(results.len(), 1);
880 assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
881 if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
882 assert_eq!(e.enabled(), Some(true));
883 }
884 assert_eq!(router.stats().routed_count(), 1);
885 }
886
887 #[test]
888 fn test_route_detailed_logs_disabled() {
889 let router = Router::new();
890 let entry = metadata_entry("DETAILED LOGS: DISABLED");
891
892 let results = router.route(&entry);
893 assert_eq!(results.len(), 1);
894 assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
895 if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
896 assert_eq!(e.enabled(), Some(false));
897 }
898 }
899
900 #[test]
901 fn test_route_metadata_no_timestamp_failure() {
902 let router = Router::new();
903 let entry = metadata_entry("DETAILED LOGS: ENABLED");
904
905 router.route(&entry);
906 assert_eq!(router.stats().timestamp_failure_count(), 1);
909 assert_eq!(router.stats().routed_count(), 1);
911 }
912
913 #[test]
914 fn test_route_unrecognized_metadata_returns_empty() {
915 let router = Router::new();
916 let entry = metadata_entry("SOME OTHER METADATA");
917
918 let results = router.route(&entry);
919 assert!(results.is_empty());
920 assert_eq!(router.stats().unknown_count(), 1);
921 }
922 }
923}