steamid_ng/
lib.rs

1//! The steamid-ng crate provides an easy-to-use [`SteamID`] type with functions to parse and render
2//! steam2 and steam3 IDs. It also supports serializing and deserializing via [serde](https://serde.rs).
3//!
4//! # Examples
5//!
6//! ```
7//! # use steamid_ng::*;
8//! let x = SteamID::from_steam64(76561197960287930).unwrap();
9//! let y = SteamID::from_steam3("[U:1:22202]").unwrap();
10//! let z = SteamID::from_steam2("STEAM_1:0:11101").unwrap();
11//! assert_eq!(x, y);
12//! assert_eq!(y, z);
13//!
14//! assert_eq!(u64::from(z), 76561197960287930);
15//! assert_eq!(y.steam2(), "STEAM_1:0:11101");
16//! assert_eq!(x.steam3(), "[U:1:22202]");
17//!
18//! assert_eq!(x.account_id(), 22202);
19//! assert_eq!(x.instance().instance_type(), Some(InstanceType::Desktop));
20//! assert_eq!(x.account_type(), AccountType::Individual);
21//! assert_eq!(x.universe(), Universe::Public);
22//! // the SteamID type also has `set_{account_id, instance, account_type, universe}` methods,
23//! // which work as you would expect.
24//! ```
25//!
26//! All constructed SteamID types are valid Steam IDs; values provided will be validated in all cases.
27//! If an ID provided by an official Valve service fails to parse, that should be considered a bug
28//! in this library, and you should open an issue [on GitHub](https://github.com/Majora320/steamid-ng/issues).
29
30#[cfg(feature = "serde")]
31use serde::{
32    Deserialize, Deserializer, Serialize,
33    de::{self, Visitor},
34};
35use std::{
36    error::Error,
37    fmt::{self, Debug, Display, Formatter},
38    str::FromStr,
39};
40
41#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
42pub struct SteamIDParseError;
43
44impl Error for SteamIDParseError {}
45
46impl Display for SteamIDParseError {
47    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
48        write!(f, "Malformed SteamID")
49    }
50}
51
52fn digit_from_ascii(byte: u8) -> Option<u8> {
53    if byte.is_ascii_digit() {
54        Some(byte - b'0')
55    } else {
56        None
57    }
58}
59
60#[cfg_attr(feature = "serde", derive(Serialize))]
61#[derive(Clone, Copy, PartialEq, Eq, Hash)]
62pub struct SteamID(u64);
63
64impl SteamID {
65    pub fn new(
66        account_id: u32,
67        instance: Instance,
68        account_type: AccountType,
69        universe: Universe,
70    ) -> Self {
71        Self(
72            (account_id as u64)
73                | ((instance.0 as u64) << 32)
74                | ((account_type as u64) << 52)
75                | ((universe as u64) << 56),
76        )
77    }
78
79    pub fn account_id(&self) -> u32 {
80        (self.0 & 0xFFFFFFFF) as u32
81    }
82
83    pub fn set_account_id(&mut self, account_id: u32) {
84        self.0 &= 0xFFFFFFFF00000000;
85        self.0 |= account_id as u64;
86    }
87
88    pub fn instance(&self) -> Instance {
89        Instance(((self.0 >> 32) & 0xFFFFF) as u32)
90    }
91
92    pub fn set_instance(&mut self, instance: Instance) {
93        self.0 &= 0xFFF00000FFFFFFFF;
94        self.0 |= (instance.0 as u64) << 32;
95    }
96
97    pub fn set_instance_type(&mut self, instance_type: InstanceType) {
98        let mut instance = self.instance();
99        instance.set_instance_type(instance_type);
100        self.set_instance(instance);
101    }
102
103    pub fn set_instance_flags(&mut self, instance_flags: InstanceFlags) {
104        let mut instance = self.instance();
105        instance.set_flags(instance_flags);
106        self.set_instance(instance);
107    }
108
109    pub fn account_type(&self) -> AccountType {
110        AccountType::try_from(((self.0 >> 52) & 0xF) as u8).expect("Account type should be valid")
111    }
112
113    pub fn set_account_type(&mut self, account_type: AccountType) {
114        self.0 &= 0xFF0FFFFFFFFFFFFF;
115        self.0 |= (account_type as u64) << 52;
116    }
117
118    pub fn universe(&self) -> Universe {
119        Universe::try_from(((self.0 >> 56) & 0xFF) as u8).expect("Universe should be valid")
120    }
121
122    pub fn set_universe(&mut self, universe: Universe) {
123        self.0 &= 0x00FFFFFFFFFFFFFF;
124        self.0 |= (universe as u64) << 56;
125    }
126
127    pub fn steam64(&self) -> u64 {
128        self.0
129    }
130
131    pub fn from_steam64(value: u64) -> Result<Self, SteamIDParseError> {
132        Self::try_from(value)
133    }
134
135    pub fn steam2(&self) -> String {
136        match self.account_type() {
137            AccountType::Individual | AccountType::Invalid => {
138                let id = self.account_id();
139                format!("STEAM_{}:{}:{}", self.universe() as u64, id & 1, id >> 1)
140            }
141            _ => self.0.to_string(),
142        }
143    }
144
145    // Parses id in the format of:
146    // ^STEAM_(universe:[0-4]):(auth_server:[0-1]):(account_id:[0-9]{1,10})$
147    pub fn from_steam2(steam2: &str) -> Result<Self, SteamIDParseError> {
148        let chunk = steam2.strip_prefix("STEAM_").ok_or(SteamIDParseError)?;
149        let mut bytes = chunk.bytes();
150
151        let mut universe: Universe = bytes
152            .next()
153            .and_then(digit_from_ascii)
154            .ok_or(SteamIDParseError)
155            .and_then(Universe::try_from)?;
156        // Apparently, games before orange box used to display as 0 incorrectly
157        // This is only an issue with steam2 ids
158        if let Universe::Invalid = universe {
159            universe = Universe::Public;
160        }
161
162        if bytes.next() != Some(b':') {
163            return Err(SteamIDParseError);
164        }
165
166        let auth_server: u32 = match bytes.next().ok_or(SteamIDParseError)? {
167            b'0' => Ok(0),
168            b'1' => Ok(1),
169            _ => Err(SteamIDParseError),
170        }?;
171
172        if bytes.next() != Some(b':') {
173            return Err(SteamIDParseError);
174        }
175
176        if bytes.len() > 10 {
177            return Err(SteamIDParseError);
178        }
179
180        let mut account_id = bytes
181            .next()
182            .and_then(digit_from_ascii)
183            .ok_or(SteamIDParseError)? as u32;
184        for b in bytes {
185            let digit = digit_from_ascii(b).ok_or(SteamIDParseError)? as u32;
186            account_id = account_id
187                .checked_mul(10)
188                .and_then(|id| id.checked_add(digit))
189                .ok_or(SteamIDParseError)?;
190        }
191        let account_id = account_id << 1 | auth_server;
192
193        Ok(Self::new(
194            account_id,
195            Instance::new(InstanceType::Desktop, InstanceFlags::None),
196            AccountType::Individual,
197            universe,
198        ))
199    }
200
201    pub fn steam3(&self) -> String {
202        let account_type = self.account_type();
203        let instance = self.instance();
204        let instance_type = instance.instance_type();
205        let instance_flags = instance.flags();
206        let mut render_instance = false;
207
208        match account_type {
209            AccountType::AnonGameServer | AccountType::Multiseat => render_instance = true,
210            AccountType::Individual => render_instance = instance_type != Some(InstanceType::Desktop),
211            _ => (),
212        };
213
214        if render_instance {
215            format!(
216                "[{}:{}:{}:{}]",
217                account_type_to_char(account_type, instance_flags),
218                self.universe() as u64,
219                self.account_id(),
220                instance.0
221            )
222        } else {
223            format!(
224                "[{}:{}:{}]",
225                account_type_to_char(account_type, instance_flags),
226                self.universe() as u64,
227                self.account_id()
228            )
229        }
230    }
231
232    // Parses id in the format of:
233    // ^\[(type:[AGMPCgcLTIUai]):(universe:[0-4]):(account_id:[0-9]{1,10})(:(instance:[0-9]+))?\]$
234    pub fn from_steam3(steam3: &str) -> Result<Self, SteamIDParseError> {
235        let mut bytes = steam3.bytes().peekable();
236
237        if bytes.next() != Some(b'[') {
238            return Err(SteamIDParseError);
239        }
240
241        let (account_type, instance_flags) = bytes
242            .next()
243            .and_then(|b| char_to_account_type(b.into()))
244            .ok_or(SteamIDParseError)?;
245
246        if bytes.next() != Some(b':') {
247            return Err(SteamIDParseError);
248        }
249
250        let universe = bytes
251            .next()
252            .and_then(digit_from_ascii)
253            .ok_or(SteamIDParseError)
254            .and_then(Universe::try_from)?;
255
256        if bytes.next() != Some(b':') {
257            return Err(SteamIDParseError);
258        }
259
260        let mut account_id = bytes
261            .next()
262            .and_then(digit_from_ascii)
263            .ok_or(SteamIDParseError)? as u32;
264        while let Some(digit) = bytes.peek().copied().and_then(digit_from_ascii) {
265            bytes.next().expect("Byte was peeked");
266            account_id = account_id
267                .checked_mul(10)
268                .and_then(|id| id.checked_add(digit as u32))
269                .ok_or(SteamIDParseError)?;
270        }
271
272        // Instance is optional. Parse it if it's there, but leave the closing ] intact
273        let instance_type = {
274            let maybe_instance_type = if bytes.peek().copied() == Some(b':') {
275                bytes.next().expect("Byte was peeked");
276
277                let mut acc = bytes
278                    .next()
279                    .and_then(digit_from_ascii)
280                    .ok_or(SteamIDParseError)? as u32;
281                while let Some(digit) = bytes.peek().copied().and_then(digit_from_ascii) {
282                    bytes.next().expect("Byte was peeked");
283                    acc = acc
284                        .checked_mul(10)
285                        .and_then(|id| id.checked_add(digit as u32))
286                        .ok_or(SteamIDParseError)?;
287                }
288
289                Some(InstanceType::try_from(acc)?)
290            } else {
291                None
292            };
293
294            match (maybe_instance_type, account_type) {
295                (None, AccountType::Individual) => InstanceType::Desktop,
296                (None, _) => InstanceType::All,
297                (Some(_), AccountType::Clan | AccountType::Chat) => InstanceType::All,
298                (Some(instance_type), _) => instance_type,
299            }
300        };
301
302        if bytes.next() != Some(b']') || bytes.next().is_some() {
303            return Err(SteamIDParseError);
304        }
305
306        Ok(Self::new(
307            account_id,
308            Instance::new(instance_type, instance_flags),
309            account_type,
310            universe,
311        ))
312    }
313}
314
315impl TryFrom<u64> for SteamID {
316    type Error = SteamIDParseError;
317
318    fn try_from(value: u64) -> Result<Self, Self::Error> {
319        AccountType::try_from((value >> 52 & 0xF) as u8)?;
320        Universe::try_from((value >> 56 & 0xFF) as u8)?;
321
322        Ok(SteamID(value))
323    }
324}
325
326impl From<SteamID> for u64 {
327    fn from(s: SteamID) -> Self {
328        s.0
329    }
330}
331
332impl FromStr for SteamID {
333    type Err = SteamIDParseError;
334    /// Tries to parse the given string as all three types of SteamID, and returns an error if
335    /// all three attempts fail. You should use [`SteamID::from_steam3`] or [`SteamID::from_steam2`]
336    /// if you know the format of the SteamID string you are trying to parse.
337    fn from_str(s: &str) -> Result<Self, Self::Err> {
338        if let Ok(u) = s.parse::<u64>() {
339            SteamID::try_from(u)
340        } else if let Ok(s) = Self::from_steam2(s) {
341            Ok(s)
342        } else if let Ok(s) = Self::from_steam3(s) {
343            Ok(s)
344        } else {
345            Err(SteamIDParseError)
346        }
347    }
348}
349
350#[cfg(feature = "serde")]
351struct SteamIDVisitor;
352#[cfg(feature = "serde")]
353impl<'de> Visitor<'de> for SteamIDVisitor {
354    type Value = SteamID;
355
356    fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
357        formatter.write_str("a SteamID")
358    }
359
360    fn visit_u64<E>(self, value: u64) -> Result<SteamID, E>
361    where
362        E: de::Error,
363    {
364        SteamID::try_from(value).map_err(|_| E::custom(format!("invalid SteamID: {}", value)))
365    }
366
367    fn visit_str<E>(self, value: &str) -> Result<SteamID, E>
368    where
369        E: de::Error,
370    {
371        SteamID::from_str(value).map_err(|_| E::custom(format!("invalid SteamID: {}", value)))
372    }
373}
374
375#[cfg(feature = "serde")]
376impl<'de> Deserialize<'de> for SteamID {
377    fn deserialize<D>(deserializer: D) -> Result<SteamID, D::Error>
378    where
379        D: Deserializer<'de>,
380    {
381        deserializer.deserialize_any(SteamIDVisitor)
382    }
383}
384
385impl Debug for SteamID {
386    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
387        write!(
388            f,
389            "SteamID({}) {{ID: {}, Instance: {:?}, Type: {:?}, Universe: {:?}}}",
390            self.0,
391            self.account_id(),
392            self.instance(),
393            self.account_type(),
394            self.universe()
395        )
396    }
397}
398
399#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
400pub enum AccountType {
401    Invalid = 0,
402    Individual = 1,
403    Multiseat = 2,
404    GameServer = 3,
405    AnonGameServer = 4,
406    Pending = 5,
407    ContentServer = 6,
408    Clan = 7,
409    Chat = 8,
410    ConsoleUser = 9,
411    AnonUser = 10,
412}
413
414impl TryFrom<u8> for AccountType {
415    type Error = SteamIDParseError;
416    fn try_from(value: u8) -> Result<Self, Self::Error> {
417        match value {
418            0 => Ok(AccountType::Invalid),
419            1 => Ok(AccountType::Individual),
420            2 => Ok(AccountType::Multiseat),
421            3 => Ok(AccountType::GameServer),
422            4 => Ok(AccountType::AnonGameServer),
423            5 => Ok(AccountType::Pending),
424            6 => Ok(AccountType::ContentServer),
425            7 => Ok(AccountType::Clan),
426            8 => Ok(AccountType::Chat),
427            9 => Ok(AccountType::ConsoleUser),
428            10 => Ok(AccountType::AnonUser),
429            _ => Err(SteamIDParseError),
430        }
431    }
432}
433
434pub fn account_type_to_char(account_type: AccountType, flags: Option<InstanceFlags>) -> char {
435    match account_type {
436        AccountType::Invalid => 'I',
437        AccountType::Individual => 'U',
438        AccountType::Multiseat => 'M',
439        AccountType::GameServer => 'G',
440        AccountType::AnonGameServer => 'A',
441        AccountType::Pending => 'P',
442        AccountType::ContentServer => 'C',
443        AccountType::Clan => 'g',
444        AccountType::Chat => match flags {
445            Some(InstanceFlags::Clan) => 'c',
446            Some(InstanceFlags::Lobby) => 'L',
447            _ => 'T',
448        },
449        AccountType::ConsoleUser => 'U',
450        AccountType::AnonUser => 'a',
451    }
452}
453
454pub fn char_to_account_type(c: char) -> Option<(AccountType, InstanceFlags)> {
455    match c {
456        'U' => Some((AccountType::Individual, InstanceFlags::None)),
457        'M' => Some((AccountType::Multiseat, InstanceFlags::None)),
458        'G' => Some((AccountType::GameServer, InstanceFlags::None)),
459        'A' => Some((AccountType::AnonGameServer, InstanceFlags::None)),
460        'P' => Some((AccountType::Pending, InstanceFlags::None)),
461        'C' => Some((AccountType::ContentServer, InstanceFlags::None)),
462        'g' => Some((AccountType::Clan, InstanceFlags::None)),
463        'T' => Some((AccountType::Chat, InstanceFlags::None)),
464        'c' => Some((AccountType::Chat, InstanceFlags::Clan)),
465        'L' => Some((AccountType::Chat, InstanceFlags::Lobby)),
466        'a' => Some((AccountType::AnonUser, InstanceFlags::None)),
467        'I' | 'i' => Some((AccountType::Invalid, InstanceFlags::None)),
468        _ => None,
469    }
470}
471
472#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
473pub enum Universe {
474    Invalid = 0,
475    Public = 1,
476    Beta = 2,
477    Internal = 3,
478    Dev = 4,
479    RC = 5,
480}
481
482impl TryFrom<u8> for Universe {
483    type Error = SteamIDParseError;
484    fn try_from(value: u8) -> Result<Self, Self::Error> {
485        match value {
486            0 => Ok(Universe::Invalid),
487            1 => Ok(Universe::Public),
488            2 => Ok(Universe::Beta),
489            3 => Ok(Universe::Internal),
490            4 => Ok(Universe::Dev),
491            5 => Ok(Universe::RC),
492            _ => Err(SteamIDParseError),
493        }
494    }
495}
496
497#[derive(Copy, Clone, PartialEq, Eq, Hash)]
498pub struct Instance(pub u32);
499
500impl Instance {
501    pub fn new(instance_type: InstanceType, flags: InstanceFlags) -> Self {
502        Instance(instance_type as u32 | (flags as u32) << 12)
503    }
504
505    pub fn instance_type(&self) -> Option<InstanceType> {
506        match self.0 & 0xFFF {
507            0 => Some(InstanceType::All),
508            1 => Some(InstanceType::Desktop),
509            2 => Some(InstanceType::Console),
510            4 => Some(InstanceType::Web),
511            _ => None,
512        }
513    }
514
515    pub fn set_instance_type(&mut self, instance_type: InstanceType) {
516        self.0 &= 0xFF000;
517        self.0 |= instance_type as u32;
518    }
519
520    pub fn flags(&self) -> Option<InstanceFlags> {
521        match self.0 >> 12 {
522            0 => Some(InstanceFlags::None),
523            0b1000_0000 => Some(InstanceFlags::Clan),
524            0b0100_0000 => Some(InstanceFlags::Lobby),
525            0b0010_0000 => Some(InstanceFlags::MMSLobby),
526            _ => None,
527        }
528    }
529
530    pub fn set_flags(&mut self, flags: InstanceFlags) {
531        self.0 &= 0x00FFF;
532        self.0 |= (flags as u32) << 12;
533    }
534}
535
536impl Debug for Instance {
537    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
538        if let Some(instance_type) = self.instance_type() && let Some(flags) = self.flags() {
539            write!(
540                f,
541                "{{Type: {:?}, Flags: {:?}}}",
542                instance_type,
543                flags
544            )
545        } else {
546            write!(f, "{}", self.0)
547        }
548    }
549}
550
551#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
552pub enum InstanceType {
553    All = 0,
554    Desktop = 1,
555    Console = 2,
556    Web = 4,
557}
558
559impl TryFrom<u32> for InstanceType {
560    type Error = SteamIDParseError;
561    fn try_from(value: u32) -> Result<Self, Self::Error> {
562        match value {
563            0 => Ok(InstanceType::All),
564            1 => Ok(InstanceType::Desktop),
565            2 => Ok(InstanceType::Console),
566            4 => Ok(InstanceType::Web),
567            _ => Err(SteamIDParseError),
568        }
569    }
570}
571
572#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
573pub enum InstanceFlags {
574    #[default]
575    None = 0,
576    Clan = 0b1000_0000,
577    Lobby = 0b0100_0000,
578    MMSLobby = 0b0010_0000,
579}
580
581impl TryFrom<u8> for InstanceFlags {
582    type Error = SteamIDParseError;
583    fn try_from(value: u8) -> Result<Self, Self::Error> {
584        match value {
585            0 => Ok(InstanceFlags::None),
586            0b1000_0000 => Ok(InstanceFlags::Clan),
587            0b0100_0000 => Ok(InstanceFlags::Lobby),
588            0b0010_0000 => Ok(InstanceFlags::MMSLobby),
589            _ => Err(SteamIDParseError),
590        }
591    }
592}