Skip to main content

steamkit/types/
steam_id.rs

1//! SteamIDs, plus the `BitVector64` helper that `GlobalID` and `GameID` use too.
2
3use std::fmt;
4
5use crate::base::generated::steam_language::{EAccountType, EUniverse};
6use crate::error::SteamError;
7
8/// A 64-bit value with masked field access.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
10pub(crate) struct BitVector64 {
11    data: u64,
12}
13
14impl BitVector64 {
15    pub(crate) fn new(value: u64) -> Self {
16        BitVector64 { data: value }
17    }
18
19    pub(crate) fn data(&self) -> u64 {
20        self.data
21    }
22
23    pub(crate) fn set_data(&mut self, value: u64) {
24        self.data = value;
25    }
26
27    /// `Data >> bitoffset & valuemask`
28    pub(crate) fn get(&self, bitoffset: u32, valuemask: u64) -> u64 {
29        self.data.wrapping_shr(bitoffset) & valuemask
30    }
31
32    /// `Data = (Data & ~(valuemask << bitoffset)) | ((value & valuemask) << bitoffset)`
33    pub(crate) fn set(&mut self, bitoffset: u32, valuemask: u64, value: u64) {
34        let shifted_mask = valuemask.wrapping_shl(bitoffset);
35        self.data = (self.data & !shifted_mask) | (value & valuemask).wrapping_shl(bitoffset);
36    }
37}
38
39/// Flags a chat [`SteamID`] can have, packed into its instance.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41#[repr(u32)]
42pub enum ChatInstanceFlags {
43    /// This flag is set for clan based chat `SteamID`s.
44    Clan = (SteamID::ACCOUNT_INSTANCE_MASK + 1) >> 1,
45    /// This flag is set for lobby based chat `SteamID`s.
46    Lobby = (SteamID::ACCOUNT_INSTANCE_MASK + 1) >> 2,
47    /// This flag is set for matchmaking lobby based chat `SteamID`s.
48    MMSLobby = (SteamID::ACCOUNT_INSTANCE_MASK + 1) >> 3,
49}
50
51impl ChatInstanceFlags {
52    /// The numeric value of this flag.
53    pub const fn value(self) -> u32 {
54        self as u32
55    }
56
57    /// Whether all bits of `other` are set.
58    pub const fn has_flag(self, other: ChatInstanceFlags) -> bool {
59        (self as u32) & (other as u32) == (other as u32)
60    }
61}
62
63impl std::ops::BitOr for ChatInstanceFlags {
64    type Output = u32;
65
66    fn bitor(self, rhs: ChatInstanceFlags) -> u32 {
67        (self as u32) | (rhs as u32)
68    }
69}
70
71/// The character for account types that don't have their own.
72const UNKNOWN_ACCOUNT_TYPE_CHAR: char = 'i';
73
74/// A 64-bit id for various objects on the Steam network.
75#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
76pub struct SteamID(u64);
77
78impl SteamID {
79    /// The account instance meaning "all instances".
80    pub const ALL_INSTANCES: u32 = 0;
81    /// The account instance value for a desktop `SteamID`.
82    pub const DESKTOP_INSTANCE: u32 = 1;
83    /// The account instance value for a console `SteamID`.
84    pub const CONSOLE_INSTANCE: u32 = 2;
85    /// The account instance for mobile or web based `SteamID`s.
86    pub const WEB_INSTANCE: u32 = 4;
87
88    /// Mask of the account id.
89    pub const ACCOUNT_ID_MASK: u32 = 0xFFFF_FFFF;
90    /// Mask of the account instance (where the chat flags live).
91    pub const ACCOUNT_INSTANCE_MASK: u32 = 0x000F_FFFF;
92
93    /// Creates a zero SteamID.
94    pub fn new() -> SteamID {
95        SteamID(0)
96    }
97
98    /// Creates a SteamID from its 64-bit value.
99    pub fn from_id(id: u64) -> SteamID {
100        SteamID(id)
101    }
102
103    /// Creates a SteamID from its parts. The instance is
104    /// [`SteamID::DESKTOP_INSTANCE`], except for clan and game server accounts,
105    /// which use [`SteamID::ALL_INSTANCES`].
106    pub fn from_parts(account_type: EAccountType, universe: EUniverse, account_id: u32) -> SteamID {
107        let mut sid = SteamID(0);
108        sid.set(account_id, universe, account_type);
109        sid
110    }
111
112    /// Creates a SteamID from its parts, with an explicit instance.
113    pub fn from_parts_instanced(
114        account_type: EAccountType,
115        universe: EUniverse,
116        account_id: u32,
117        instance: u32,
118    ) -> SteamID {
119        let mut sid = SteamID(0);
120        sid.instanced_set(account_id, instance, universe, account_type);
121        sid
122    }
123
124    /// Sets the parts of this SteamID.
125    pub fn set(&mut self, account_id: u32, universe: EUniverse, account_type: EAccountType) {
126        self.set_account_id(account_id);
127        self.set_account_universe(universe);
128        self.set_account_type(account_type);
129
130        if account_type == EAccountType::Clan || account_type == EAccountType::GameServer {
131            self.set_account_instance(Self::ALL_INSTANCES);
132        } else {
133            self.set_account_instance(Self::DESKTOP_INSTANCE);
134        }
135    }
136
137    /// Sets the parts of this SteamID, with an explicit instance.
138    pub fn instanced_set(
139        &mut self,
140        account_id: u32,
141        instance: u32,
142        universe: EUniverse,
143        account_type: EAccountType,
144    ) {
145        self.set_account_id(account_id);
146        self.set_account_universe(universe);
147        self.set_account_type(account_type);
148        self.set_account_instance(instance);
149    }
150
151    /// Sets this SteamID from a Steam2 "STEAM_" string and a universe. Returns
152    /// `false` if the string isn't valid.
153    pub fn set_from_string(&mut self, steam_id: &str, universe: EUniverse) -> bool {
154        let Some((_universe_digit, auth_server, account_id)) = parse_steam2_id(steam_id) else {
155            return false;
156        };
157
158        self.set_account_universe(universe);
159        self.set_account_instance(1);
160        self.set_account_type(EAccountType::Individual);
161        self.set_account_id(account_id.wrapping_shl(1) | auth_server);
162
163        true
164    }
165
166    /// Sets this SteamID from a Steam3 "\[X:1:2:3\]" string. Returns `false` if
167    /// the string isn't valid.
168    pub fn set_from_steam3_string(&mut self, steam_id: &str) -> bool {
169        let Some(parsed) = parse_steam3_id(steam_id) else {
170            return false;
171        };
172
173        let mut instance = match parsed.instance {
174            Some(instance) => instance,
175            None => match parsed.type_char {
176                'g' | 'T' | 'c' | 'L' => 0,
177                _ => 1,
178            },
179        };
180
181        match parsed.type_char {
182            'c' => {
183                instance |= ChatInstanceFlags::Clan.value();
184                self.set_account_type(EAccountType::Chat);
185            }
186            'L' => {
187                instance |= ChatInstanceFlags::Lobby.value();
188                self.set_account_type(EAccountType::Chat);
189            }
190            'i' => {
191                self.set_account_type(EAccountType::Invalid);
192            }
193            other => {
194                let account_type = account_type_from_char(other);
195                self.set_account_type(account_type);
196            }
197        }
198
199        self.set_account_universe(universe_from_value(parsed.universe));
200        self.set_account_instance(instance);
201        self.set_account_id(parsed.account_id);
202
203        true
204    }
205
206    /// Sets this SteamID from its 64-bit value.
207    pub fn set_from_uint64(&mut self, ul_steam_id: u64) {
208        self.0 = ul_steam_id;
209    }
210
211    /// The 64-bit value.
212    pub fn convert_to_u64(&self) -> u64 {
213        self.0
214    }
215
216    /// A key for grouping accounts that differ only in their instance.
217    pub fn get_static_account_key(&self) -> u64 {
218        ((self.account_universe_raw() as u64) << 56)
219            + ((self.account_type_raw() as u64) << 52)
220            + (self.account_id() as u64)
221    }
222
223    /// The raw 64-bit value, instance bits included.
224    pub fn value(&self) -> u64 {
225        self.0
226    }
227
228    /// The raw 4-bit account type. Kept raw because the generated enum can't
229    /// hold the invalid values 11-15.
230    fn account_type_raw(&self) -> u32 {
231        ((self.0 >> 52) & 0xF) as u32
232    }
233
234    /// The raw 8-bit universe. Kept raw because the generated enum can't hold
235    /// the invalid values 5-255.
236    fn account_universe_raw(&self) -> u32 {
237        ((self.0 >> 56) & 0xFF) as u32
238    }
239
240    /// Whether this is a blank anonymous account.
241    pub fn is_blank_anon_account(&self) -> bool {
242        self.account_id() == 0 && self.is_anon_account() && self.account_instance() == 0
243    }
244
245    /// Whether this is a game server account.
246    pub fn is_game_server_account(&self) -> bool {
247        let account_type = self.account_type_raw();
248        account_type == EAccountType::GameServer.value() as u32
249            || account_type == EAccountType::AnonGameServer.value() as u32
250    }
251
252    /// Whether this is a persistent game server account.
253    pub fn is_persistent_game_server_account(&self) -> bool {
254        self.account_type_raw() == EAccountType::GameServer.value() as u32
255    }
256
257    /// Whether this is an anonymous game server account.
258    pub fn is_anon_game_server_account(&self) -> bool {
259        self.account_type_raw() == EAccountType::AnonGameServer.value() as u32
260    }
261
262    /// Whether this is a content server account.
263    pub fn is_content_server_account(&self) -> bool {
264        self.account_type_raw() == EAccountType::ContentServer.value() as u32
265    }
266
267    /// Whether this is a clan account.
268    pub fn is_clan_account(&self) -> bool {
269        self.account_type_raw() == EAccountType::Clan.value() as u32
270    }
271
272    /// Whether this is a chat account.
273    pub fn is_chat_account(&self) -> bool {
274        self.account_type_raw() == EAccountType::Chat.value() as u32
275    }
276
277    /// Whether this is a lobby.
278    pub fn is_lobby(&self) -> bool {
279        self.is_chat_account() && (self.account_instance() & ChatInstanceFlags::Lobby.value()) > 0
280    }
281
282    /// Whether this is an individual account.
283    pub fn is_individual_account(&self) -> bool {
284        let account_type = self.account_type_raw();
285        account_type == EAccountType::Individual.value() as u32
286            || account_type == EAccountType::ConsoleUser.value() as u32
287    }
288
289    /// Whether this is an anonymous account.
290    pub fn is_anon_account(&self) -> bool {
291        let account_type = self.account_type_raw();
292        account_type == EAccountType::AnonUser.value() as u32
293            || account_type == EAccountType::AnonGameServer.value() as u32
294    }
295
296    /// Whether this is an anonymous user account.
297    pub fn is_anon_user_account(&self) -> bool {
298        self.account_type_raw() == EAccountType::AnonUser.value() as u32
299    }
300
301    /// Whether this is a console user account.
302    pub fn is_console_user_account(&self) -> bool {
303        self.account_type_raw() == EAccountType::ConsoleUser.value() as u32
304    }
305
306    /// Whether this is a valid SteamID.
307    pub fn is_valid(&self) -> bool {
308        let account_type = self.account_type_raw();
309        if account_type <= EAccountType::Invalid.value() as u32
310            || account_type > EAccountType::AnonUser.value() as u32
311        {
312            return false;
313        }
314
315        let universe = self.account_universe_raw();
316        if universe <= EUniverse::Invalid.value() as u32 || universe > EUniverse::Dev.value() as u32 {
317            return false;
318        }
319
320        if account_type == EAccountType::Individual.value() as u32
321            && (self.account_id() == 0 || self.account_instance() > Self::WEB_INSTANCE)
322        {
323            return false;
324        }
325
326        if account_type == EAccountType::Clan.value() as u32
327            && (self.account_id() == 0 || self.account_instance() != 0)
328        {
329            return false;
330        }
331
332        if account_type == EAccountType::GameServer.value() as u32 && self.account_id() == 0 {
333            return false;
334        }
335
336        true
337    }
338
339    /// The account id.
340    pub fn account_id(&self) -> u32 {
341        (self.0 & Self::ACCOUNT_ID_MASK as u64) as u32
342    }
343
344    /// Sets the account id.
345    pub fn set_account_id(&mut self, value: u32) {
346        let mask = Self::ACCOUNT_ID_MASK as u64;
347        self.0 = (self.0 & !mask) | (value as u64 & mask);
348    }
349
350    /// The account instance.
351    pub fn account_instance(&self) -> u32 {
352        ((self.0 >> 32) & Self::ACCOUNT_INSTANCE_MASK as u64) as u32
353    }
354
355    /// Sets the account instance.
356    pub fn set_account_instance(&mut self, value: u32) {
357        let mask = (Self::ACCOUNT_INSTANCE_MASK as u64) << 32;
358        self.0 = (self.0 & !mask) | ((value as u64) & Self::ACCOUNT_INSTANCE_MASK as u64) << 32;
359    }
360
361    /// The account type. Values outside the generated enum (11-15) come back as
362    /// [`EAccountType::Invalid`]; [`SteamID::render`] still renders them
363    /// correctly.
364    pub fn account_type(&self) -> EAccountType {
365        account_type_from_value(self.account_type_raw())
366    }
367
368    /// Sets the account type.
369    pub fn set_account_type(&mut self, value: EAccountType) {
370        let raw = (value.value() as u64) & 0xF;
371        self.0 = (self.0 & !(0xF_u64 << 52)) | (raw << 52);
372    }
373
374    /// The universe. Values outside the generated enum (5-255) come back as
375    /// [`EUniverse::Invalid`].
376    pub fn account_universe(&self) -> EUniverse {
377        universe_from_value(self.account_universe_raw())
378    }
379
380    /// Sets the universe.
381    pub fn set_account_universe(&mut self, value: EUniverse) {
382        let raw = (value.value() as u64) & 0xFF;
383        self.0 = (self.0 & !(0xFF_u64 << 56)) | (raw << 56);
384    }
385
386    /// Renders this SteamID as Steam3 ("\[U:1:123\]") or Steam2 ("STEAM_0:1:61").
387    pub fn render(&self, steam3: bool) -> String {
388        if steam3 {
389            self.render_steam3()
390        } else {
391            self.render_steam2()
392        }
393    }
394
395    /// Converts this clan id to a chat id, as a raw 64-bit value. Ids that
396    /// aren't clan accounts are converted anyway; use
397    /// [`SteamID::try_to_chat_id`] for a checked version.
398    pub fn to_chat_id(&self) -> u64 {
399        let mut chat_id = self.0;
400        chat_id = (chat_id & !((Self::ACCOUNT_INSTANCE_MASK as u64) << 32))
401            | (ChatInstanceFlags::Clan.value() as u64) << 32;
402        chat_id = (chat_id & !(0xF_u64 << 52)) | ((EAccountType::Chat.value() as u64 & 0xF) << 52);
403        chat_id
404    }
405
406    /// Converts this clan id to a chat id, failing if it isn't a clan account.
407    pub fn try_to_chat_id(&self) -> Result<SteamID, SteamError> {
408        if !self.is_clan_account() {
409            return Err(SteamError::invalid_argument(
410                "Only Clan IDs can be converted to Chat IDs.",
411            ));
412        }
413
414        Ok(SteamID(self.to_chat_id()))
415    }
416
417    /// Converts this chat id to a clan id, if it is a clan chat.
418    pub fn try_get_clan_id(&self) -> Option<SteamID> {
419        if self.is_chat_account() && self.account_instance() == ChatInstanceFlags::Clan.value() {
420            let mut group_id = SteamID(self.0);
421            group_id.set_account_type(EAccountType::Clan);
422            group_id.set_account_instance(0);
423            Some(group_id)
424        } else {
425            None
426        }
427    }
428
429    /// Renders the Steam2 form.
430    fn render_steam2(&self) -> String {
431        let account_type_raw = self.account_type_raw();
432
433        if account_type_raw == EAccountType::Invalid.value() as u32
434            || account_type_raw == EAccountType::Individual.value() as u32
435        {
436            let universe_digit = if self.account_universe_raw() <= EUniverse::Public.value() as u32
437            {
438                "0".to_string()
439            } else {
440                self.account_universe_raw().to_string()
441            };
442            format!(
443                "STEAM_{}:{}:{}",
444                universe_digit,
445                self.account_id() & 1,
446                self.account_id() >> 1
447            )
448        } else {
449            // Only individual (and invalid) accounts have a Steam2 form; anything
450            // else renders as the plain 64-bit number.
451            self.0.to_string()
452        }
453    }
454
455    /// Renders the Steam3 form.
456    fn render_steam3(&self) -> String {
457        let account_type_raw = self.account_type_raw();
458        let mut account_type_char = account_type_char(account_type_raw);
459
460        if account_type_raw == EAccountType::Chat.value() as u32 {
461            let instance = self.account_instance();
462            if instance & ChatInstanceFlags::Clan.value() != 0 {
463                account_type_char = 'c';
464            } else if instance & ChatInstanceFlags::Lobby.value() != 0 {
465                account_type_char = 'L';
466            }
467        }
468
469        let render_instance = match account_type_raw {
470            raw if raw == EAccountType::AnonGameServer.value() as u32
471                || raw == EAccountType::Multiseat.value() as u32 =>
472            {
473                true
474            }
475            raw if raw == EAccountType::Individual.value() as u32 => {
476                self.account_instance() != Self::DESKTOP_INSTANCE
477            }
478            _ => false,
479        };
480
481        if render_instance {
482            format!(
483                "[{}:{}:{}:{}]",
484                account_type_char,
485                self.account_universe_raw(),
486                self.account_id(),
487                self.account_instance()
488            )
489        } else {
490            format!(
491                "[{}:{}:{}]",
492                account_type_char,
493                self.account_universe_raw(),
494                self.account_id()
495            )
496        }
497    }
498}
499
500impl fmt::Display for SteamID {
501    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502        f.write_str(&self.render(true))
503    }
504}
505
506impl fmt::Debug for SteamID {
507    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
508        fmt::Display::fmt(self, f)
509    }
510}
511
512impl From<SteamID> for u64 {
513    fn from(sid: SteamID) -> u64 {
514        sid.0
515    }
516}
517
518impl From<u64> for SteamID {
519    fn from(id: u64) -> SteamID {
520        SteamID(id)
521    }
522}
523
524/// Maps a raw account type to the generated `EAccountType` enum.
525fn account_type_from_value(raw: u32) -> EAccountType {
526    match raw {
527        0 => EAccountType::Invalid,
528        1 => EAccountType::Individual,
529        2 => EAccountType::Multiseat,
530        3 => EAccountType::GameServer,
531        4 => EAccountType::AnonGameServer,
532        5 => EAccountType::Pending,
533        6 => EAccountType::ContentServer,
534        7 => EAccountType::Clan,
535        8 => EAccountType::Chat,
536        9 => EAccountType::ConsoleUser,
537        10 => EAccountType::AnonUser,
538        _ => EAccountType::Invalid,
539    }
540}
541
542/// Maps a raw universe to the generated `EUniverse` enum.
543fn universe_from_value(raw: u32) -> EUniverse {
544    match raw {
545        0 => EUniverse::Invalid,
546        1 => EUniverse::Public,
547        2 => EUniverse::Beta,
548        3 => EUniverse::Internal,
549        4 => EUniverse::Dev,
550        _ => EUniverse::Invalid,
551    }
552}
553
554/// The account type for a Steam3 type character.
555fn account_type_from_char(ch: char) -> EAccountType {
556    match ch {
557        'A' => EAccountType::AnonGameServer,
558        'G' => EAccountType::GameServer,
559        'M' => EAccountType::Multiseat,
560        'P' => EAccountType::Pending,
561        'C' => EAccountType::ContentServer,
562        'g' => EAccountType::Clan,
563        'T' => EAccountType::Chat,
564        'I' => EAccountType::Invalid,
565        'U' => EAccountType::Individual,
566        'a' => EAccountType::AnonUser,
567        _ => EAccountType::Invalid,
568    }
569}
570
571/// The Steam3 type character of an account type. `ConsoleUser` has none, so it
572/// gets the "unknown" character.
573fn account_type_char(raw: u32) -> char {
574    match raw {
575        0 => 'I',
576        1 => 'U',
577        2 => 'M',
578        3 => 'G',
579        4 => 'A',
580        5 => 'P',
581        6 => 'C',
582        7 => 'g',
583        8 => 'T',
584        10 => 'a',
585        _ => UNKNOWN_ACCOUNT_TYPE_CHAR,
586    }
587}
588
589/// The parts of a parsed Steam3 id.
590struct Steam3Parts {
591    type_char: char,
592    universe: u32,
593    account_id: u32,
594    instance: Option<u32>,
595}
596
597/// Parses a Steam2 id like "STEAM_0:1:12345". Matches
598/// `STEAM_([0-4]):([0-1]):([0-9]+)`, case-insensitive and unanchored, so the
599/// leftmost occurrence in the input is used.
600fn parse_steam2_id(text: &str) -> Option<(u32, u32, u32)> {
601    let bytes = text.as_bytes();
602    let mut search_from = 0usize;
603
604    while let Some(offset) = find_ignore_case(bytes, b"STEAM_", search_from) {
605        if let Some(parsed) = parse_steam2_at(bytes, offset + 6) {
606            return Some(parsed);
607        }
608        search_from = offset + 1;
609    }
610
611    None
612}
613
614fn parse_steam2_at(bytes: &[u8], mut index: usize) -> Option<(u32, u32, u32)> {
615    if index >= bytes.len() || !bytes[index].is_ascii_digit() || bytes[index] > b'4' {
616        return None;
617    }
618    let universe = (bytes[index] - b'0') as u32;
619    index += 1;
620
621    if index >= bytes.len() || bytes[index] != b':' {
622        return None;
623    }
624    index += 1;
625
626    if index >= bytes.len() || !bytes[index].is_ascii_digit() || bytes[index] > b'1' {
627        return None;
628    }
629    let auth_server = (bytes[index] - b'0') as u32;
630    index += 1;
631
632    if index >= bytes.len() || bytes[index] != b':' {
633        return None;
634    }
635    index += 1;
636
637    let digits_start = index;
638    while index < bytes.len() && bytes[index].is_ascii_digit() {
639        index += 1;
640    }
641    if index == digits_start {
642        return None;
643    }
644
645    let account_id = parse_u32(&bytes[digits_start..index])?;
646
647    Some((universe, auth_server, account_id))
648}
649
650/// Parses a Steam3 id like "[U:1:1234]". Matches
651/// `\[([AGMPCgcLTIUai]):([0-4]):([0-9]+)(:([0-9]+))?\]`, or failing that the
652/// older form with the instance in parentheses,
653/// `\[([AGMPCgcLTIUai]):([0-4]):([0-9]+)(\(([0-9]+)\))?\]`.
654fn parse_steam3_id(text: &str) -> Option<Steam3Parts> {
655    parse_steam3_with(text, false).or_else(|| parse_steam3_with(text, true))
656}
657
658fn parse_steam3_with(text: &str, fallback: bool) -> Option<Steam3Parts> {
659    let bytes = text.as_bytes();
660    let mut search_from = 0usize;
661
662    while let Some(offset) = bytes[search_from..]
663        .iter()
664        .position(|&b| b == b'[')
665        .map(|pos| pos + search_from)
666    {
667        if let Some(parsed) = parse_steam3_at(bytes, offset, fallback) {
668            return Some(parsed);
669        }
670        search_from = offset + 1;
671    }
672
673    None
674}
675
676fn parse_steam3_at(bytes: &[u8], mut index: usize, fallback: bool) -> Option<Steam3Parts> {
677    if index + 1 >= bytes.len() {
678        return None;
679    }
680
681    let type_char = bytes[index + 1] as char;
682    if !matches!(
683        type_char,
684        'A' | 'G' | 'M' | 'P' | 'C' | 'g' | 'c' | 'L' | 'T' | 'I' | 'U' | 'a' | 'i'
685    ) {
686        return None;
687    }
688    index += 2;
689
690    if index >= bytes.len() || bytes[index] != b':' {
691        return None;
692    }
693    index += 1;
694
695    if index >= bytes.len() || !bytes[index].is_ascii_digit() || bytes[index] > b'4' {
696        return None;
697    }
698    let universe = (bytes[index] - b'0') as u32;
699    index += 1;
700
701    if index >= bytes.len() || bytes[index] != b':' {
702        return None;
703    }
704    index += 1;
705
706    let digits_start = index;
707    let mut digits_end = index;
708    while digits_end < bytes.len() && bytes[digits_end].is_ascii_digit() {
709        digits_end += 1;
710    }
711    if digits_end == digits_start {
712        return None;
713    }
714
715    // Like a greedy regex that backtracks when the optional instance doesn't
716    // match: try shorter and shorter account ids.
717    let mut account_end = digits_end;
718    while account_end > digits_start {
719        let Some(account_id) = parse_u32(&bytes[digits_start..account_end]) else {
720            return None;
721        };
722        match parse_steam3_tail(bytes, account_end, fallback) {
723            Some(instance) => {
724                return Some(Steam3Parts {
725                    type_char,
726                    universe,
727                    account_id,
728                    instance,
729                })
730            }
731            None => account_end -= 1,
732        }
733    }
734
735    None
736}
737
738/// Parses the optional instance part plus the closing bracket.
739fn parse_steam3_tail(bytes: &[u8], index: usize, fallback: bool) -> Option<Option<u32>> {
740    let separator = if fallback { b'(' } else { b':' };
741
742    if index < bytes.len() && bytes[index] == separator {
743        let mut cursor = index + 1;
744        let digits_start = cursor;
745        while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
746            cursor += 1;
747        }
748        if cursor > digits_start {
749            if let Some(instance) = parse_u32(&bytes[digits_start..cursor]) {
750                if fallback {
751                    // The fallback form is `(\d+)]`, so the parenthesis and
752                    // the bracket are two separate characters.
753                    if cursor < bytes.len() && bytes[cursor] == b')' {
754                        cursor += 1;
755                        if cursor < bytes.len() && bytes[cursor] == b']' {
756                            return Some(Some(instance));
757                        }
758                    }
759                } else if cursor < bytes.len() && bytes[cursor] == b']' {
760                    // The regular form is `:\d+]`, so the bracket ends the
761                    // match.
762                    return Some(Some(instance));
763                }
764            }
765        }
766    }
767
768    if index < bytes.len() && bytes[index] == b']' {
769        return Some(None);
770    }
771
772    None
773}
774
775/// Parses ASCII digits into a `u32`, failing on overflow.
776fn parse_u32(digits: &[u8]) -> Option<u32> {
777    let text = std::str::from_utf8(digits).ok()?;
778    let value: u64 = text.parse().ok()?;
779    if value > u32::MAX as u64 {
780        return None;
781    }
782    Some(value as u32)
783}
784
785/// Finds the leftmost case insensitive occurrence of `needle`.
786fn find_ignore_case(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
787    if needle.is_empty() || haystack.len() < needle.len() {
788        return None;
789    }
790
791    let last = haystack.len() - needle.len();
792    let mut index = from;
793    while index <= last {
794        if haystack[index..index + needle.len()]
795            .iter()
796            .zip(needle.iter())
797            .all(|(a, b)| a.eq_ignore_ascii_case(b))
798        {
799            return Some(index);
800        }
801        index += 1;
802    }
803
804    None
805}