Skip to main content

steamid/
steamid.rs

1//! SteamID struct and implementation.
2
3use std::{fmt, str::FromStr, sync::LazyLock};
4
5use regex::Regex;
6
7use crate::{
8    enums::{chat_instance_flags, masks, AccountType, Instance, Universe},
9    error::SteamIdError,
10};
11
12// Compiled regex patterns (lazily initialized)
13static STEAM2_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^STEAM_([0-5]):([0-1]):([0-9]+)$").unwrap());
14static STEAM3_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\[([a-zA-Z]):([0-5]):([0-9]+)(:[0-9]+)?\]$").unwrap());
15static STEAMID64_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").unwrap());
16
17/// Represents a Steam ID with all its components.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct SteamID {
20    /// The Steam universe this ID belongs to.
21    pub universe: Universe,
22    /// The type of account this ID represents.
23    pub account_type: AccountType,
24    /// The instance of the account.
25    pub instance: u32,
26    /// The unique account identifier.
27    pub account_id: u32,
28}
29
30impl Default for SteamID {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl SteamID {
37    /// Creates a new invalid SteamID.
38    pub fn new() -> Self {
39        SteamID {
40            universe: Universe::Invalid,
41            account_type: AccountType::Invalid,
42            instance: Instance::All as u32,
43            account_id: 0,
44        }
45    }
46
47    /// Creates a SteamID from an individual account ID.
48    ///
49    /// This is a convenience method for creating a typical user SteamID
50    /// in the public universe with a desktop instance.
51    pub fn from_individual_account_id(account_id: u32) -> Self {
52        SteamID {
53            universe: Universe::Public,
54            account_type: AccountType::Individual,
55            instance: Instance::Desktop as u32,
56            account_id,
57        }
58    }
59
60    /// Parse a SteamID from a 64-bit integer.
61    pub fn from_steam_id64(id: u64) -> Self {
62        let account_id = (id & masks::ACCOUNT_ID_MASK) as u32;
63        let instance = ((id >> 32) & masks::ACCOUNT_INSTANCE_MASK as u64) as u32;
64        let account_type = AccountType::from_u8(((id >> 52) & 0xF) as u8);
65        let universe = Universe::from_u8((id >> 56) as u8);
66
67        SteamID { universe, account_type, instance, account_id }
68    }
69
70    /// Returns whether Steam would consider this ID to be "valid".
71    ///
72    /// This does not check whether the given ID belongs to a real account,
73    /// nor does it check that the given ID is for an individual account
74    /// or in the public universe.
75    pub fn is_valid(&self) -> bool {
76        // Type must be valid
77        if self.account_type == AccountType::Invalid || (self.account_type as u8) > 10 {
78            return false;
79        }
80
81        // Universe must be valid
82        if self.universe == Universe::Invalid || (self.universe as u8) > 4 {
83            return false;
84        }
85
86        // Individual accounts need valid account_id and instance <= WEB
87        if self.account_type == AccountType::Individual && (self.account_id == 0 || self.instance > Instance::Web as u32) {
88            return false;
89        }
90
91        // Clans need valid account_id and instance must be ALL
92        if self.account_type == AccountType::Clan && (self.account_id == 0 || self.instance != Instance::All as u32) {
93            return false;
94        }
95
96        // Game servers need valid account_id
97        if self.account_type == AccountType::GameServer && self.account_id == 0 {
98            return false;
99        }
100
101        true
102    }
103
104    /// Returns whether this SteamID is valid and belongs to an individual user
105    /// in the public universe with a desktop instance.
106    ///
107    /// This is what most people think of when they think of a SteamID.
108    /// Does not check whether the account actually exists.
109    pub fn is_valid_individual(&self) -> bool {
110        self.universe == Universe::Public && self.account_type == AccountType::Individual && self.instance == Instance::Desktop as u32 && self.is_valid()
111    }
112
113    /// Checks whether this ID is for a legacy group chat.
114    pub fn is_group_chat(&self) -> bool {
115        self.account_type == AccountType::Chat && (self.instance & chat_instance_flags::CLAN) != 0
116    }
117
118    /// Checks whether this ID is for a game lobby.
119    pub fn is_lobby(&self) -> bool {
120        self.account_type == AccountType::Chat && ((self.instance & chat_instance_flags::LOBBY) != 0 || (self.instance & chat_instance_flags::MMS_LOBBY) != 0)
121    }
122
123    /// Renders the ID in Steam2 format (e.g. "STEAM_0:0:23071901").
124    ///
125    /// # Arguments
126    /// * `newer_format` - If true, use 1 as the first digit instead of 0 for
127    ///   the public universe.
128    ///
129    /// # Errors
130    /// Returns an error if this is not an individual account type.
131    pub fn steam2(&self, newer_format: bool) -> Result<String, SteamIdError> {
132        if self.account_type != AccountType::Individual {
133            return Err(SteamIdError::NotIndividual);
134        }
135
136        let universe = if !newer_format && self.universe == Universe::Public { 0 } else { self.universe as u8 };
137
138        Ok(format!("STEAM_{}:{}:{}", universe, self.account_id & 1, self.account_id / 2))
139    }
140
141    /// Renders the ID in Steam3 format (e.g. "[U:1:46143802]").
142    pub fn steam3(&self) -> String {
143        let mut type_char = self.account_type.to_char();
144
145        // Special handling for chat types
146        if (self.instance & chat_instance_flags::CLAN) != 0 {
147            type_char = 'c';
148        } else if (self.instance & chat_instance_flags::LOBBY) != 0 {
149            type_char = 'L';
150        }
151
152        let should_render_instance = self.account_type == AccountType::AnonGameServer || self.account_type == AccountType::Multiseat || (self.account_type == AccountType::Individual && self.instance != Instance::Desktop as u32);
153
154        if should_render_instance {
155            format!("[{}:{}:{}:{}]", type_char, self.universe as u8, self.account_id, self.instance)
156        } else {
157            format!("[{}:{}:{}]", type_char, self.universe as u8, self.account_id)
158        }
159    }
160
161    /// Renders the ID in 64-bit decimal format.
162    pub fn steam_id64(&self) -> u64 {
163        let universe = (self.universe as u64) << 56;
164        let account_type = (self.account_type as u64) << 52;
165        let instance = (self.instance as u64) << 32;
166        let account_id = self.account_id as u64;
167
168        universe | account_type | instance | account_id
169    }
170
171    // Private parsing methods
172    fn parse_steam2(input: &str) -> Option<Self> {
173        let caps = STEAM2_REGEX.captures(input)?;
174
175        let universe_num: u8 = caps.get(1)?.as_str().parse().ok()?;
176        let mod_num: u32 = caps.get(2)?.as_str().parse().ok()?;
177        let account_id_half: u32 = caps.get(3)?.as_str().parse().ok()?;
178
179        // If universe is 0, treat it as PUBLIC (1)
180        let universe = if universe_num == 0 { Universe::Public } else { Universe::from_u8(universe_num) };
181
182        Some(SteamID {
183            universe,
184            account_type: AccountType::Individual,
185            instance: Instance::Desktop as u32,
186            account_id: (account_id_half * 2) + mod_num,
187        })
188    }
189
190    fn parse_steam3(input: &str) -> Option<Self> {
191        let caps = STEAM3_REGEX.captures(input)?;
192
193        let type_char = caps.get(1)?.as_str().chars().next()?;
194        let universe_num: u8 = caps.get(2)?.as_str().parse().ok()?;
195        let account_id: u32 = caps.get(3)?.as_str().parse().ok()?;
196
197        let universe = Universe::from_u8(universe_num);
198
199        let mut instance: u32 = Instance::All as u32;
200        if let Some(instance_match) = caps.get(4) {
201            // Remove leading colon and parse
202            let instance_str = &instance_match.as_str()[1..];
203            instance = instance_str.parse().ok()?;
204        }
205
206        let account_type = match type_char {
207            'U' => {
208                // Individual - default to DESKTOP if no explicit instance
209                if caps.get(4).is_none() {
210                    instance = Instance::Desktop as u32;
211                }
212                AccountType::Individual
213            }
214            'c' => {
215                instance |= chat_instance_flags::CLAN;
216                AccountType::Chat
217            }
218            'L' => {
219                instance |= chat_instance_flags::LOBBY;
220                AccountType::Chat
221            }
222            _ => AccountType::from_char(type_char),
223        };
224
225        Some(SteamID { universe, account_type, instance, account_id })
226    }
227
228    fn parse_steam_id64(input: &str) -> Option<Self> {
229        if !STEAMID64_REGEX.is_match(input) {
230            return None;
231        }
232
233        let id: u64 = input.parse().ok()?;
234        Some(Self::from_steam_id64(id))
235    }
236}
237
238impl FromStr for SteamID {
239    type Err = SteamIdError;
240
241    fn from_str(input: &str) -> Result<Self, Self::Err> {
242        // Try Steam2 format
243        if let Some(sid) = Self::parse_steam2(input) {
244            return Ok(sid);
245        }
246
247        // Try Steam3 format
248        if let Some(sid) = Self::parse_steam3(input) {
249            return Ok(sid);
250        }
251
252        // Try SteamID64 format
253        if let Some(sid) = Self::parse_steam_id64(input) {
254            return Ok(sid);
255        }
256
257        Err(SteamIdError::InvalidFormat(input.to_string()))
258    }
259}
260
261impl TryFrom<&str> for SteamID {
262    type Error = SteamIdError;
263
264    fn try_from(value: &str) -> Result<Self, Self::Error> {
265        value.parse()
266    }
267}
268
269impl TryFrom<String> for SteamID {
270    type Error = SteamIdError;
271
272    fn try_from(value: String) -> Result<Self, Self::Error> {
273        value.parse()
274    }
275}
276
277impl From<u64> for SteamID {
278    fn from(value: u64) -> Self {
279        Self::from_steam_id64(value)
280    }
281}
282
283impl fmt::Display for SteamID {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{}", self.steam_id64())
286    }
287}
288
289impl serde::Serialize for SteamID {
290    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
291    where
292        S: serde::Serializer,
293    {
294        serializer.serialize_u64(self.steam_id64())
295    }
296}
297
298impl<'de> serde::Deserialize<'de> for SteamID {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: serde::Deserializer<'de>,
302    {
303        struct SteamIDVisitor;
304
305        impl<'de> serde::de::Visitor<'de> for SteamIDVisitor {
306            type Value = SteamID;
307
308            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
309                formatter.write_str("a SteamID64 as a number or string")
310            }
311
312            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
313            where
314                E: serde::de::Error,
315            {
316                Ok(SteamID::from(value))
317            }
318
319            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
320            where
321                E: serde::de::Error,
322            {
323                value.parse().map_err(serde::de::Error::custom)
324            }
325        }
326
327        deserializer.deserialize_any(SteamIDVisitor)
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_parameterless_construction() {
337        let sid = SteamID::new();
338        assert_eq!(sid.universe, Universe::Invalid);
339        assert_eq!(sid.account_type, AccountType::Invalid);
340        assert_eq!(sid.instance, Instance::All as u32);
341        assert_eq!(sid.account_id, 0);
342    }
343
344    #[test]
345    fn test_from_individual_account_id() {
346        let sid = SteamID::from_individual_account_id(46143802);
347        assert_eq!(sid.universe, Universe::Public);
348        assert_eq!(sid.account_type, AccountType::Individual);
349        assert_eq!(sid.instance, Instance::Desktop as u32);
350        assert_eq!(sid.account_id, 46143802);
351        assert!(sid.is_valid());
352        assert!(sid.is_valid_individual());
353    }
354
355    #[test]
356    fn test_steam2id_construction_universe_0() {
357        let sid: SteamID = "STEAM_0:0:23071901".parse().unwrap();
358        assert_eq!(sid.universe, Universe::Public);
359        assert_eq!(sid.account_type, AccountType::Individual);
360        assert_eq!(sid.instance, Instance::Desktop as u32);
361        assert_eq!(sid.account_id, 46143802);
362    }
363
364    #[test]
365    fn test_steam2id_construction_universe_1() {
366        let sid: SteamID = "STEAM_1:1:23071901".parse().unwrap();
367        assert_eq!(sid.universe, Universe::Public);
368        assert_eq!(sid.account_type, AccountType::Individual);
369        assert_eq!(sid.instance, Instance::Desktop as u32);
370        assert_eq!(sid.account_id, 46143803);
371    }
372
373    #[test]
374    fn test_steam3id_construction_individual() {
375        let sid: SteamID = "[U:1:46143802]".parse().unwrap();
376        assert_eq!(sid.universe, Universe::Public);
377        assert_eq!(sid.account_type, AccountType::Individual);
378        assert_eq!(sid.instance, Instance::Desktop as u32);
379        assert_eq!(sid.account_id, 46143802);
380    }
381
382    #[test]
383    fn test_steam3id_construction_gameserver() {
384        let sid: SteamID = "[G:1:31]".parse().unwrap();
385        assert_eq!(sid.universe, Universe::Public);
386        assert_eq!(sid.account_type, AccountType::GameServer);
387        assert_eq!(sid.instance, Instance::All as u32);
388        assert_eq!(sid.account_id, 31);
389        assert!(sid.is_valid());
390        assert!(!sid.is_valid_individual());
391    }
392
393    #[test]
394    fn test_steam3id_construction_anon_gameserver() {
395        let sid: SteamID = "[A:1:46124:11245]".parse().unwrap();
396        assert_eq!(sid.universe, Universe::Public);
397        assert_eq!(sid.account_type, AccountType::AnonGameServer);
398        assert_eq!(sid.instance, 11245);
399        assert_eq!(sid.account_id, 46124);
400    }
401
402    #[test]
403    fn test_steam3id_construction_lobby() {
404        let sid: SteamID = "[L:1:12345]".parse().unwrap();
405        assert_eq!(sid.universe, Universe::Public);
406        assert_eq!(sid.account_type, AccountType::Chat);
407        assert_eq!(sid.instance, chat_instance_flags::LOBBY);
408        assert_eq!(sid.account_id, 12345);
409    }
410
411    #[test]
412    fn test_steam3id_construction_lobby_with_instanceid() {
413        let sid: SteamID = "[L:1:12345:55]".parse().unwrap();
414        assert_eq!(sid.universe, Universe::Public);
415        assert_eq!(sid.account_type, AccountType::Chat);
416        assert_eq!(sid.instance, chat_instance_flags::LOBBY | 55);
417        assert_eq!(sid.account_id, 12345);
418    }
419
420    #[test]
421    fn test_steamid64_construction_individual() {
422        let sid: SteamID = "76561198006409530".parse().unwrap();
423        assert_eq!(sid.universe, Universe::Public);
424        assert_eq!(sid.account_type, AccountType::Individual);
425        assert_eq!(sid.instance, Instance::Desktop as u32);
426        assert_eq!(sid.account_id, 46143802);
427    }
428
429    #[test]
430    fn test_steamid64_construction_clan() {
431        let sid: SteamID = "103582791434202956".parse().unwrap();
432        assert_eq!(sid.universe, Universe::Public);
433        assert_eq!(sid.account_type, AccountType::Clan);
434        assert_eq!(sid.instance, Instance::All as u32);
435        assert_eq!(sid.account_id, 4681548);
436    }
437
438    #[test]
439    fn test_steamid64_from_u64() {
440        let sid = SteamID::from(76561198006409530u64);
441        assert_eq!(sid.universe, Universe::Public);
442        assert_eq!(sid.account_type, AccountType::Individual);
443        assert_eq!(sid.instance, Instance::Desktop as u32);
444        assert_eq!(sid.account_id, 46143802);
445    }
446
447    #[test]
448    fn test_invalid_construction() {
449        let result: Result<SteamID, _> = "invalid input".parse();
450        assert!(result.is_err());
451    }
452
453    #[test]
454    fn test_steam2id_rendering_universe_0() {
455        let mut sid = SteamID::new();
456        sid.universe = Universe::Public;
457        sid.account_type = AccountType::Individual;
458        sid.instance = Instance::Desktop as u32;
459        sid.account_id = 46143802;
460        assert_eq!(sid.steam2(false).unwrap(), "STEAM_0:0:23071901");
461    }
462
463    #[test]
464    fn test_steam2id_rendering_universe_1() {
465        let mut sid = SteamID::new();
466        sid.universe = Universe::Public;
467        sid.account_type = AccountType::Individual;
468        sid.instance = Instance::Desktop as u32;
469        sid.account_id = 46143802;
470        assert_eq!(sid.steam2(true).unwrap(), "STEAM_1:0:23071901");
471    }
472
473    #[test]
474    fn test_steam2id_rendering_non_individual() {
475        let mut sid = SteamID::new();
476        sid.universe = Universe::Public;
477        sid.account_type = AccountType::Clan;
478        sid.instance = Instance::Desktop as u32;
479        sid.account_id = 4681548;
480        assert!(sid.steam2(false).is_err());
481    }
482
483    #[test]
484    fn test_steam3id_rendering_individual() {
485        let mut sid = SteamID::new();
486        sid.universe = Universe::Public;
487        sid.account_type = AccountType::Individual;
488        sid.instance = Instance::Desktop as u32;
489        sid.account_id = 46143802;
490        assert_eq!(sid.steam3(), "[U:1:46143802]");
491    }
492
493    #[test]
494    fn test_steam3id_rendering_anon_gameserver() {
495        let mut sid = SteamID::new();
496        sid.universe = Universe::Public;
497        sid.account_type = AccountType::AnonGameServer;
498        sid.instance = 41511;
499        sid.account_id = 43253156;
500        assert_eq!(sid.steam3(), "[A:1:43253156:41511]");
501    }
502
503    #[test]
504    fn test_steam3id_rendering_lobby() {
505        let mut sid = SteamID::new();
506        sid.universe = Universe::Public;
507        sid.account_type = AccountType::Chat;
508        sid.instance = chat_instance_flags::LOBBY;
509        sid.account_id = 451932;
510        assert_eq!(sid.steam3(), "[L:1:451932]");
511    }
512
513    #[test]
514    fn test_steamid64_rendering_individual() {
515        let mut sid = SteamID::new();
516        sid.universe = Universe::Public;
517        sid.account_type = AccountType::Individual;
518        sid.instance = Instance::Desktop as u32;
519        sid.account_id = 46143802;
520        assert_eq!(sid.steam_id64(), 76561198006409530);
521        assert_eq!(sid.to_string(), "76561198006409530");
522    }
523
524    #[test]
525    fn test_steamid64_rendering_anon_gameserver() {
526        let mut sid = SteamID::new();
527        sid.universe = Universe::Public;
528        sid.account_type = AccountType::AnonGameServer;
529        sid.instance = 188991;
530        sid.account_id = 42135013;
531        assert_eq!(sid.steam_id64(), 90883702753783269);
532    }
533
534    #[test]
535    fn test_invalid_new_id() {
536        let sid = SteamID::new();
537        assert!(!sid.is_valid());
538    }
539
540    #[test]
541    fn test_invalid_individual_instance() {
542        let sid: SteamID = "[U:1:46143802:10]".parse().unwrap();
543        assert!(!sid.is_valid());
544        assert!(!sid.is_valid_individual());
545    }
546
547    #[test]
548    fn test_invalid_non_all_clan_instance() {
549        let sid: SteamID = "[g:1:4681548:2]".parse().unwrap();
550        assert!(!sid.is_valid());
551    }
552
553    #[test]
554    fn test_invalid_gameserver_accountid_0() {
555        let sid: SteamID = "[G:1:0]".parse().unwrap();
556        assert!(!sid.is_valid());
557    }
558
559    #[test]
560    fn test_is_group_chat() {
561        let mut sid = SteamID::new();
562        sid.account_type = AccountType::Chat;
563        sid.instance = chat_instance_flags::CLAN;
564        assert!(sid.is_group_chat());
565        assert!(!sid.is_lobby());
566    }
567
568    #[test]
569    fn test_is_lobby() {
570        let mut sid = SteamID::new();
571        sid.account_type = AccountType::Chat;
572        sid.instance = chat_instance_flags::LOBBY;
573        assert!(!sid.is_group_chat());
574        assert!(sid.is_lobby());
575    }
576}