Skip to main content

steam_client/utils/
parsing.rs

1//! Pure parsing functions for Steam protocol messages.
2//!
3//! This module extracts parsing logic from `MessageHandler` into standalone
4//! pure functions, making them easier to unit test without event emission
5//! concerns.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use steam_client::parsing::{parse_logon_response, LogonData};
11//!
12//! let bytes = /* protobuf bytes */;
13//! match parse_logon_response(&bytes) {
14//!     Ok(data) => tracing::info!("Logged in as {}", data.steam_id.steam3()),
15//!     Err(e) => tracing::info!("Parse error: {:?}", e),
16//! }
17//! ```
18
19use std::collections::HashMap;
20
21use prost::Message;
22use steam_enums::{EChatEntryType, EFriendRelationship, EPersonaState, EResult};
23use steamid::SteamID;
24
25use super::{
26    binary_kv::{parse_binary_kv, BinaryKvValue},
27    vdf::{parse_vdf, VdfValue},
28};
29
30/// Error type for parsing operations.
31#[derive(Debug, Clone, PartialEq)]
32pub enum ParseError {
33    /// Failed to decode protobuf message.
34    DecodeError(String),
35    /// Missing required field.
36    MissingField(String),
37    /// Invalid data.
38    InvalidData(String),
39}
40
41impl std::fmt::Display for ParseError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            ParseError::DecodeError(msg) => write!(f, "Decode error: {}", msg),
45            ParseError::MissingField(field) => write!(f, "Missing field: {}", field),
46            ParseError::InvalidData(msg) => write!(f, "Invalid data: {}", msg),
47        }
48    }
49}
50
51impl std::error::Error for ParseError {}
52
53//=============================================================================
54// Parsed Data Structures
55//=============================================================================
56
57/// Parsed logon response data.
58#[derive(Debug, Clone)]
59pub struct LogonData {
60    pub steam_id: SteamID,
61    pub eresult: EResult,
62    pub heartbeat_seconds: i32,
63    pub public_ip: Option<std::net::Ipv4Addr>,
64    pub vanity_url: Option<String>,
65    pub cell_id: Option<u32>,
66    pub client_instance_id: Option<u64>,
67}
68
69/// Parsed friends list data.
70#[derive(Debug, Clone)]
71pub struct FriendsListData {
72    pub incremental: bool,
73    pub friends: Vec<FriendData>,
74}
75
76/// Parsed friend entry.
77#[derive(Debug, Clone)]
78pub struct FriendData {
79    pub steam_id: SteamID,
80    pub relationship: EFriendRelationship,
81}
82
83/// Parsed persona state data.
84#[derive(Debug, Clone)]
85pub struct PersonaData {
86    pub steam_id: SteamID,
87    pub player_name: String,
88    pub persona_state: EPersonaState,
89    pub avatar_hash: Option<String>,
90    pub game_name: Option<String>,
91    pub game_id: Option<u64>,
92    pub last_logoff: Option<u32>,
93    pub last_logon: Option<u32>,
94    pub last_seen_online: Option<u32>,
95    pub game_server_ip: Option<u32>,
96    pub game_server_port: Option<u32>,
97    pub rich_presence: HashMap<String, String>,
98}
99
100/// Parsed license entry.
101#[derive(Debug, Clone)]
102pub struct LicenseData {
103    pub package_id: u32,
104    pub time_created: u32,
105    pub license_type: u32,
106    pub flags: u32,
107    pub access_token: u64,
108}
109
110/// Parsed chat message data.
111#[derive(Debug, Clone)]
112pub enum ChatData {
113    Message { sender: SteamID, message: String, chat_entry_type: EChatEntryType, timestamp: u32, ordinal: u32 },
114    Typing { sender: SteamID },
115}
116
117/// Parsed PICS product info.
118#[derive(Debug, Clone)]
119pub struct ProductInfoData {
120    pub apps: HashMap<u32, AppInfoData>,
121    pub packages: HashMap<u32, PackageInfoData>,
122    pub unknown_apps: Vec<u32>,
123    pub unknown_packages: Vec<u32>,
124}
125
126/// Parsed app info.
127#[derive(Debug, Clone)]
128pub struct AppInfoData {
129    pub app_id: u32,
130    pub change_number: u32,
131    pub missing_token: bool,
132    pub app_info: Option<VdfValue>,
133}
134
135/// Parsed package info.
136#[derive(Debug, Clone)]
137pub struct PackageInfoData {
138    pub package_id: u32,
139    pub change_number: u32,
140    pub missing_token: bool,
141    pub package_info: Option<BinaryKvValue>,
142}
143
144/// Parsed access tokens response.
145#[derive(Debug, Clone)]
146pub struct AccessTokensData {
147    pub app_tokens: HashMap<u32, u64>,
148    pub package_tokens: HashMap<u32, u64>,
149    pub app_denied: Vec<u32>,
150    pub package_denied: Vec<u32>,
151}
152
153/// Parsed PICS changes response.
154#[derive(Debug, Clone)]
155pub struct ChangesData {
156    pub current_change_number: u32,
157    pub app_changes: Vec<AppChange>,
158    pub package_changes: Vec<PackageChange>,
159}
160
161/// Parsed app change entry.
162#[derive(Debug, Clone)]
163pub struct AppChange {
164    pub app_id: u32,
165    pub change_number: u32,
166    pub needs_token: bool,
167}
168
169/// Parsed package change entry.
170#[derive(Debug, Clone)]
171pub struct PackageChange {
172    pub package_id: u32,
173    pub change_number: u32,
174    pub needs_token: bool,
175}
176
177//=============================================================================
178// Pure Parsing Functions
179//=============================================================================
180
181/// Parse a logon response message.
182///
183/// # Returns
184/// - `Ok(LogonData)` with the steam ID and result on success
185/// - `Err(ParseError)` if decoding fails
186pub fn parse_logon_response(body: &[u8]) -> Result<LogonData, ParseError> {
187    let msg = steam_protos::CMsgClientLogonResponse::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
188
189    let eresult = EResult::from_i32(msg.eresult.unwrap_or(2)).unwrap_or(EResult::Fail);
190    let steam_id = SteamID::from_steam_id64(msg.client_supplied_steamid.unwrap_or(0));
191
192    let public_ip = msg.public_ip.and_then(|ip_msg| match ip_msg.ip {
193        Some(steam_protos::cmsg_ip_address::Ip::V4(addr)) => Some(std::net::Ipv4Addr::from(addr.to_be_bytes())),
194        _ => None,
195    });
196
197    Ok(LogonData {
198        steam_id,
199        eresult,
200        heartbeat_seconds: msg.heartbeat_seconds.unwrap_or(0),
201        public_ip,
202        vanity_url: msg.vanity_url,
203        cell_id: msg.cell_id,
204        client_instance_id: msg.client_instance_id,
205    })
206}
207
208/// Parse a logged off message.
209///
210/// # Returns
211/// - `Ok(EResult)` with the result code
212/// - `Err(ParseError)` if decoding fails
213pub fn parse_logged_off(body: &[u8]) -> Result<EResult, ParseError> {
214    let msg = steam_protos::CMsgClientLoggedOff::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
215
216    Ok(EResult::from_i32(msg.eresult.unwrap_or(2)).unwrap_or(EResult::Fail))
217}
218
219/// Parse a friends list message.
220///
221/// # Returns
222/// - `Ok(FriendsListData)` with the friends list
223/// - `Err(ParseError)` if decoding fails
224pub fn parse_friends_list(body: &[u8]) -> Result<FriendsListData, ParseError> {
225    let msg = steam_protos::CMsgClientFriendsList::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
226
227    let friends = msg
228        .friends
229        .iter()
230        .map(|f| FriendData {
231            steam_id: SteamID::from_steam_id64(f.ulfriendid.unwrap_or(0)),
232            relationship: EFriendRelationship::from_i32(f.efriendrelationship.unwrap_or(0) as i32).unwrap_or(EFriendRelationship::None),
233        })
234        .collect();
235
236    Ok(FriendsListData { incremental: msg.bincremental.unwrap_or(false), friends })
237}
238
239/// Parse a persona state message.
240///
241/// # Returns
242/// - `Ok(PersonaData)` with the persona info
243/// - `Err(ParseError)` if decoding fails or no persona data present
244pub fn parse_persona_state(body: &[u8]) -> Result<PersonaData, ParseError> {
245    let msg = steam_protos::CMsgClientPersonaState::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
246
247    let friend = msg.friends.first().ok_or_else(|| ParseError::MissingField("friends".to_string()))?;
248
249    let rich_presence = friend.rich_presence.iter().map(|rp| (rp.key.clone().unwrap_or_default(), rp.value.clone().unwrap_or_default())).collect();
250
251    Ok(PersonaData {
252        steam_id: SteamID::from_steam_id64(friend.friendid.unwrap_or(0)),
253        player_name: friend.player_name.clone().unwrap_or_default(),
254        persona_state: EPersonaState::from_i32(friend.persona_state.unwrap_or(0) as i32).unwrap_or(EPersonaState::Offline),
255        avatar_hash: friend.avatar_hash.as_ref().map(hex::encode),
256        game_name: friend.game_name.clone(),
257        game_id: friend.gameid,
258        last_logoff: friend.last_logoff,
259        last_logon: friend.last_logon,
260        last_seen_online: friend.last_seen_online,
261        game_server_ip: friend.game_server_ip,
262        game_server_port: friend.game_server_port,
263        rich_presence,
264    })
265}
266
267/// Parse a license list message.
268///
269/// # Returns
270/// - `Ok(Vec<LicenseData>)` with the license list
271/// - `Err(ParseError)` if decoding fails
272pub fn parse_license_list(body: &[u8]) -> Result<Vec<LicenseData>, ParseError> {
273    let msg = steam_protos::CMsgClientLicenseList::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
274
275    Ok(msg
276        .licenses
277        .iter()
278        .map(|l| LicenseData {
279            package_id: l.package_id.unwrap_or(0),
280            time_created: l.time_created.unwrap_or(0),
281            license_type: l.license_type.unwrap_or(0),
282            flags: l.flags.unwrap_or(0),
283            access_token: l.access_token.unwrap_or(0),
284        })
285        .collect())
286}
287
288/// Parse a CM list message.
289///
290/// # Returns
291/// - `Ok(Vec<String>)` with the server addresses
292/// - `Err(ParseError)` if decoding fails
293pub fn parse_cm_list(body: &[u8]) -> Result<Vec<String>, ParseError> {
294    let msg = steam_protos::CMsgClientCMList::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
295
296    Ok(msg.cm_websocket_addresses)
297}
298
299/// Parse a service method message (friend messages/typing).
300///
301/// # Returns
302/// - `Ok(ChatData)` with the chat data
303/// - `Err(ParseError)` if decoding fails
304pub fn parse_service_method(body: &[u8]) -> Result<ChatData, ParseError> {
305    let msg = steam_protos::CFriendMessagesIncomingMessageNotification::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
306
307    let sender = SteamID::from_steam_id64(msg.steamid_friend.unwrap_or(0));
308
309    if msg.chat_entry_type == Some(EChatEntryType::Typing as i32) {
310        Ok(ChatData::Typing { sender })
311    } else {
312        Ok(ChatData::Message {
313            sender,
314            message: msg.message.unwrap_or_default(),
315            chat_entry_type: EChatEntryType::from_i32(msg.chat_entry_type.unwrap_or(1)).unwrap_or(EChatEntryType::ChatMsg),
316            timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
317            ordinal: msg.ordinal.unwrap_or(0),
318        })
319    }
320}
321
322/// Parse a PICS product info response.
323///
324/// # Returns
325/// - `Ok(ProductInfoData)` with apps and packages info
326/// - `Err(ParseError)` if decoding fails
327pub fn parse_pics_product_info(body: &[u8]) -> Result<ProductInfoData, ParseError> {
328    let msg = steam_protos::CMsgClientPICSProductInfoResponse::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
329
330    let mut apps = HashMap::new();
331    let mut packages = HashMap::new();
332
333    for app in &msg.apps {
334        let app_id = app.appid.unwrap_or(0);
335        let app_info = app.buffer.as_ref().and_then(|buf| {
336            let text = String::from_utf8_lossy(buf);
337            let text = text.trim_end_matches('\0');
338            parse_vdf(text).ok().and_then(|v| match v {
339                VdfValue::Object(mut map) => map.remove("appinfo").or(Some(VdfValue::Object(map))),
340                _ => Some(v),
341            })
342        });
343
344        apps.insert(
345            app_id,
346            AppInfoData {
347                app_id,
348                change_number: app.change_number.unwrap_or(0),
349                missing_token: app.missing_token.unwrap_or(false),
350                app_info,
351            },
352        );
353    }
354
355    for pkg in &msg.packages {
356        let package_id = pkg.packageid.unwrap_or(0);
357        let package_info = pkg.buffer.as_ref().and_then(|buf| {
358            parse_binary_kv(buf).ok().and_then(|v| match v {
359                BinaryKvValue::Object(mut map) => {
360                    let pkg_id_str = package_id.to_string();
361                    map.remove(&pkg_id_str).or(Some(BinaryKvValue::Object(map)))
362                }
363                _ => Some(v),
364            })
365        });
366
367        packages.insert(
368            package_id,
369            PackageInfoData {
370                package_id,
371                change_number: pkg.change_number.unwrap_or(0),
372                missing_token: pkg.missing_token.unwrap_or(false),
373                package_info,
374            },
375        );
376    }
377
378    Ok(ProductInfoData { apps, packages, unknown_apps: msg.unknown_appids.clone(), unknown_packages: msg.unknown_packageids.clone() })
379}
380
381/// Parse a PICS access tokens response.
382///
383/// # Returns
384/// - `Ok(AccessTokensData)` with token mappings
385/// - `Err(ParseError)` if decoding fails
386pub fn parse_pics_access_tokens(body: &[u8]) -> Result<AccessTokensData, ParseError> {
387    let msg = steam_protos::CMsgClientPICSAccessTokenResponse::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
388
389    let mut app_tokens = HashMap::new();
390    let mut package_tokens = HashMap::new();
391
392    for token in &msg.app_access_tokens {
393        app_tokens.insert(token.appid.unwrap_or(0), token.access_token.unwrap_or(0));
394    }
395
396    for token in &msg.package_access_tokens {
397        package_tokens.insert(token.packageid.unwrap_or(0), token.access_token.unwrap_or(0));
398    }
399
400    Ok(AccessTokensData {
401        app_tokens,
402        package_tokens,
403        app_denied: msg.app_denied_tokens.clone(),
404        package_denied: msg.package_denied_tokens.clone(),
405    })
406}
407
408/// Parse a PICS changes since response.
409///
410/// # Returns
411/// - `Ok(ChangesData)` with change information
412/// - `Err(ParseError)` if decoding fails
413pub fn parse_pics_changes(body: &[u8]) -> Result<ChangesData, ParseError> {
414    let msg = steam_protos::CMsgClientPICSChangesSinceResponse::decode(body).map_err(|e| ParseError::DecodeError(e.to_string()))?;
415
416    let app_changes = msg
417        .app_changes
418        .iter()
419        .map(|c| AppChange {
420            app_id: c.appid.unwrap_or(0),
421            change_number: c.change_number.unwrap_or(0),
422            needs_token: c.needs_token.unwrap_or(false),
423        })
424        .collect();
425
426    let package_changes = msg
427        .package_changes
428        .iter()
429        .map(|c| PackageChange {
430            package_id: c.packageid.unwrap_or(0),
431            change_number: c.change_number.unwrap_or(0),
432            needs_token: c.needs_token.unwrap_or(false),
433        })
434        .collect();
435
436    Ok(ChangesData { current_change_number: msg.current_change_number.unwrap_or(0), app_changes, package_changes })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    //=========================================================================
444    // LogonResponse Tests
445    //=========================================================================
446
447    #[test]
448    fn test_parse_logon_response_success() {
449        let msg = steam_protos::CMsgClientLogonResponse {
450            eresult: Some(1), // OK
451            client_supplied_steamid: Some(76561198000000000),
452            ..Default::default()
453        };
454        let bytes = msg.encode_to_vec();
455
456        let result = parse_logon_response(&bytes).expect("parse failed");
457        assert_eq!(result.eresult, EResult::OK);
458        assert_eq!(result.steam_id.steam_id64(), 76561198000000000);
459    }
460
461    #[test]
462    fn test_parse_logon_response_failure() {
463        let msg = steam_protos::CMsgClientLogonResponse {
464            eresult: Some(5), // InvalidPassword
465            ..Default::default()
466        };
467        let bytes = msg.encode_to_vec();
468
469        let result = parse_logon_response(&bytes).expect("parse failed");
470        assert_eq!(result.eresult, EResult::InvalidPassword);
471    }
472
473    #[test]
474    fn test_parse_logon_response_invalid_bytes() {
475        let bytes = vec![0xFF, 0xFF, 0xFF];
476        let result = parse_logon_response(&bytes);
477        assert!(matches!(result, Err(ParseError::DecodeError(_))));
478    }
479
480    //=========================================================================
481    // LoggedOff Tests
482    //=========================================================================
483
484    #[test]
485    fn test_parse_logged_off() {
486        let msg = steam_protos::CMsgClientLoggedOff {
487            eresult: Some(6), // LoggedInElsewhere
488        };
489        let bytes = msg.encode_to_vec();
490
491        let result = parse_logged_off(&bytes).expect("parse failed");
492        assert_eq!(result, EResult::LoggedInElsewhere);
493    }
494
495    //=========================================================================
496    // FriendsList Tests
497    //=========================================================================
498
499    #[test]
500    fn test_parse_friends_list_empty() {
501        let msg = steam_protos::CMsgClientFriendsList { bincremental: Some(false), friends: vec![], ..Default::default() };
502        let bytes = msg.encode_to_vec();
503
504        let result = parse_friends_list(&bytes).expect("parse failed");
505        assert!(!result.incremental);
506        assert!(result.friends.is_empty());
507    }
508
509    #[test]
510    fn test_parse_friends_list_with_friends() {
511        let msg = steam_protos::CMsgClientFriendsList {
512            bincremental: Some(true),
513            friends: vec![
514                steam_protos::cmsg_client_friends_list::Friend {
515                    ulfriendid: Some(76561198000000001),
516                    efriendrelationship: Some(3), // Friend
517                },
518                steam_protos::cmsg_client_friends_list::Friend {
519                    ulfriendid: Some(76561198000000002),
520                    efriendrelationship: Some(2), // RequestRecipient
521                },
522            ],
523            ..Default::default()
524        };
525        let bytes = msg.encode_to_vec();
526
527        let result = parse_friends_list(&bytes).expect("parse failed");
528        assert!(result.incremental);
529        assert_eq!(result.friends.len(), 2);
530        assert_eq!(result.friends[0].steam_id.steam_id64(), 76561198000000001);
531    }
532
533    //=========================================================================
534    // PersonaState Tests
535    //=========================================================================
536
537    #[test]
538    fn test_parse_persona_state() {
539        let msg = steam_protos::CMsgClientPersonaState {
540            friends: vec![steam_protos::cmsg_client_persona_state::Friend {
541                friendid: Some(76561198000000000),
542                player_name: Some("TestPlayer".to_string()),
543                persona_state: Some(1), // Online
544                ..Default::default()
545            }],
546            ..Default::default()
547        };
548        let bytes = msg.encode_to_vec();
549
550        let result = parse_persona_state(&bytes).expect("parse failed");
551        assert_eq!(result.player_name, "TestPlayer");
552        assert_eq!(result.persona_state, EPersonaState::Online);
553    }
554
555    #[test]
556    fn test_parse_persona_state_no_friends() {
557        let msg = steam_protos::CMsgClientPersonaState { friends: vec![], ..Default::default() };
558        let bytes = msg.encode_to_vec();
559
560        let result = parse_persona_state(&bytes);
561        assert!(matches!(result, Err(ParseError::MissingField(_))));
562    }
563
564    //=========================================================================
565    // LicenseList Tests
566    //=========================================================================
567
568    #[test]
569    fn test_parse_license_list() {
570        let msg = steam_protos::CMsgClientLicenseList {
571            licenses: vec![steam_protos::cmsg_client_license_list::License {
572                package_id: Some(12345),
573                time_created: Some(1600000000),
574                license_type: Some(1),
575                flags: Some(0),
576                access_token: Some(999),
577                ..Default::default()
578            }],
579            ..Default::default()
580        };
581        let bytes = msg.encode_to_vec();
582
583        let result = parse_license_list(&bytes).expect("parse failed");
584        assert_eq!(result.len(), 1);
585        assert_eq!(result[0].package_id, 12345);
586        assert_eq!(result[0].access_token, 999);
587    }
588
589    //=========================================================================
590    // CMList Tests
591    //=========================================================================
592
593    #[test]
594    fn test_parse_cm_list() {
595        let msg = steam_protos::CMsgClientCMList {
596            cm_websocket_addresses: vec!["wss://cm1.steampowered.com".to_string(), "wss://cm2.steampowered.com".to_string()],
597            ..Default::default()
598        };
599        let bytes = msg.encode_to_vec();
600
601        let result = parse_cm_list(&bytes).expect("parse failed");
602        assert_eq!(result.len(), 2);
603        assert!(result[0].starts_with("wss://"));
604    }
605
606    //=========================================================================
607    // ServiceMethod (Chat) Tests
608    //=========================================================================
609
610    #[test]
611    fn test_parse_service_method_message() {
612        let msg = steam_protos::CFriendMessagesIncomingMessageNotification {
613            steamid_friend: Some(76561198000000000),
614            message: Some("Hello!".to_string()),
615            chat_entry_type: Some(1), // ChatMsg
616            rtime32_server_timestamp: Some(1600000000),
617            ordinal: Some(5),
618            ..Default::default()
619        };
620        let bytes = msg.encode_to_vec();
621
622        let result = parse_service_method(&bytes).expect("parse failed");
623        match result {
624            ChatData::Message { sender, message, timestamp, .. } => {
625                assert_eq!(sender.steam_id64(), 76561198000000000);
626                assert_eq!(message, "Hello!");
627                assert_eq!(timestamp, 1600000000);
628            }
629            _ => panic!("Expected Message variant"),
630        }
631    }
632
633    #[test]
634    fn test_parse_service_method_typing() {
635        let msg = steam_protos::CFriendMessagesIncomingMessageNotification {
636            steamid_friend: Some(76561198000000000),
637            chat_entry_type: Some(EChatEntryType::Typing as i32),
638            ..Default::default()
639        };
640        let bytes = msg.encode_to_vec();
641
642        let result = parse_service_method(&bytes).expect("parse failed");
643        assert!(matches!(result, ChatData::Typing { .. }));
644    }
645
646    //=========================================================================
647    // PICS Tests
648    //=========================================================================
649
650    #[test]
651    fn test_parse_pics_access_tokens() {
652        let msg = steam_protos::CMsgClientPICSAccessTokenResponse {
653            app_access_tokens: vec![steam_protos::cmsg_client_pics_access_token_response::AppToken { appid: Some(440), access_token: Some(123456789) }],
654            package_access_tokens: vec![steam_protos::cmsg_client_pics_access_token_response::PackageToken { packageid: Some(550), access_token: Some(987654321) }],
655            app_denied_tokens: vec![10],
656            package_denied_tokens: vec![20],
657        };
658        let bytes = msg.encode_to_vec();
659
660        let result = parse_pics_access_tokens(&bytes).expect("parse failed");
661
662        assert_eq!(result.app_tokens.get(&440), Some(&123456789));
663        assert_eq!(result.package_tokens.get(&550), Some(&987654321));
664        assert_eq!(result.app_denied, vec![10]);
665        assert_eq!(result.package_denied, vec![20]);
666    }
667
668    #[test]
669    fn test_parse_pics_changes() {
670        let msg = steam_protos::CMsgClientPICSChangesSinceResponse {
671            current_change_number: Some(100),
672            app_changes: vec![steam_protos::cmsg_client_pics_changes_since_response::AppChange { appid: Some(440), change_number: Some(99), needs_token: Some(true) }],
673            package_changes: vec![steam_protos::cmsg_client_pics_changes_since_response::PackageChange { packageid: Some(550), change_number: Some(98), needs_token: Some(false) }],
674            ..Default::default()
675        };
676        let bytes = msg.encode_to_vec();
677
678        let result = parse_pics_changes(&bytes).expect("parse failed");
679
680        assert_eq!(result.current_change_number, 100);
681        assert_eq!(result.app_changes.len(), 1);
682        assert_eq!(result.app_changes[0].app_id, 440);
683        assert!(result.app_changes[0].needs_token);
684
685        assert_eq!(result.package_changes.len(), 1);
686        assert_eq!(result.package_changes[0].package_id, 550);
687        assert!(!result.package_changes[0].needs_token);
688    }
689}