gdlib 0.4.1

Rust library for editing Geometry Dash savefiles
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
//! All sub-component structs of ccgamemanager::CCGameManager.

use std::collections::HashMap;

use bitflags::bitflags;
use plist::{Dictionary, Value};

use crate::{
    ccgamemanager::{
        IntMap,
        achievements::{GDAchievement, MAX_ACHIEVEMENT_INDEX},
    },
    cclocallevels::{gdlevel::GDLevel, gdlist::GDList, gdobj::GDObject},
    repr_t,
};

/// Type of a valid achievement progress value: unsigned integer that supports a maximum value of at least 10,000.
///
/// For achievements that are not completed, the game uses one of two formats to represent completion progress:
/// - A value on the interval \[0, 100] which is an integer percentage
/// - A value on that interval\[0, 10'000] which is a percentage with double decimal point precision; e.g. 1962 is 19.62%
///
/// A value of 100 represents a completed achievement regardless of format.
/// It is unknown what format is used by the game for specific achievements, however it is known that for values
/// greater than 100 that the second format is used.
pub type AchievementProgress = u16;

// ---- Substructs ----

/// Player info: username, UDID, user id, all icon info
#[derive(Debug, Default, Clone)]
pub struct GDPlayerInfo {
    /// Internal key: `playerName`
    pub username: String,
    /// Internal key: `playerUDID`
    pub udid: String,
    /// Internal key: `playerUserID`
    pub user_id: i32,
    /// Internal key: `playerFrame`
    pub icon_cube: i32,
    /// Internal key: `playerShip`
    pub icon_ship: i32,
    /// Internal key: `playerBall`
    pub icon_ball: i32,
    /// Internal key: `playerBird`
    pub icon_ufo: i32,
    /// Internal key: `playerDart`
    pub icon_wave: i32,
    /// Internal key: `playerRobot`
    pub icon_robot: i32,
    /// Internal key: `playerSpider`
    pub icon_spider: i32,
    /// Internal key: `playerSwing`
    pub icon_swing: i32,
    /// Internal key: `playerColor`
    pub player_col1: i32,
    /// Internal key: `playerColor2`
    pub player_col2: i32,
    /// Internal key: `playerColor3`
    pub player_col_glow: i32,
    /// Internal key: `playerStreak`
    pub icon_streak: i32,
    /// Internal key: `playerShipStreak`
    pub ship_streak: i32,
    /// Internal key: `playerDeathEffect`
    pub death_effect: i32,
    /// Internal key: `playerJetpack`
    pub icon_jetpack: i32,
    /// Internal key: `playerIconType`
    pub icon_type: i32,
    /// Internal key: `playerGlow`
    pub using_glow: bool,
    /// Internal key: `hasRP`
    pub is_moderator: bool,
}

/// Player-specific statistics.
#[derive(Debug, Clone)]
pub struct GDStatistics {
    /// Number of times this player has launched GD
    ///
    /// Internal key: `bootups`
    pub bootups: i32,
    /// All official levels that the player has progress on.
    ///
    /// Internal key: `GLM_01`
    pub official_level_progresses: Vec<GDLevel>,
    /// Internal key: `GLM_03`
    pub online_levels_played: Vec<GDLevel>,
    /// Internal key: `GS_20`
    pub demon_keys: i32,
    /// All level IDs the player has submitted difficulty ratings for
    ///
    /// Internal key: `GLM_13`
    pub submitted_ratings: Vec<i32>,
    /// All demon levels the player has submitted ratings on
    ///
    /// Internal key: `GLM_15`
    pub submitted_ratings_demons: Vec<i32>,
    /// All gauntlet levels that the player has progress on
    ///
    /// Internal key: `GLM_16`
    pub gauntlet_levels_played: Vec<GDLevel>,
    /// All completed dailies in the form `{timely id: level}`
    ///
    /// Internal keys: `GLM_10`
    pub completed_dailies: IntMap<GDLevel>,
    /// All vanilla achievements. Each element is the achievement's progress is an integer from 0 - 100 inclusive.
    /// Use [`GDStatistics::get_vanilla_achievement`] to get an achievement's progress when using the [`GDAchievement`] enum.
    ///
    /// The split between vanilla achievements and custom achievements is to optimize indexing of achievements.
    pub achievements: [AchievementProgress; MAX_ACHIEVEMENT_INDEX],
    /// Achievements that are unaccounted for in `self.achievements`. This includes any undocumented or custom-made achievements,
    /// including those added by mods.
    pub custom_achievements: HashMap<String, AchievementProgress>,
}

// manual impl exists because `Default` isn't implemented for `[T; N]` such that N > 32
// and there are a lot more than 32 achievements in GD
impl Default for GDStatistics {
    fn default() -> Self {
        Self {
            bootups: 0,
            official_level_progresses: Vec::new(),
            online_levels_played: Vec::new(),
            demon_keys: 0,
            submitted_ratings: Vec::new(),
            submitted_ratings_demons: Vec::new(),
            gauntlet_levels_played: Vec::new(),
            completed_dailies: HashMap::default(),
            achievements: [0; MAX_ACHIEVEMENT_INDEX],
            custom_achievements: HashMap::default(),
        }
    }
}

/// User's configuration of the game.
#[derive(Debug, Default, Clone)]
pub struct GDConfig {
    /// Internal key: `bgVolume`
    pub bgm_volume: f32,
    /// Internal key: `sfxVolume`
    pub sfx_volume: f32,
    /// Internal key: `texQuality`
    pub texture_quality: TextureQuality,
    /// Internal key: `resolution`
    pub resolution: Resolution,
    /// Internal key: `showSongMarkers`
    pub show_song_markers: bool,
    /// Internal key: `showProgressBar`
    pub show_progress_bar: bool,
    /// Internal key: `clickedGarage`
    pub has_clicked_garage: bool,
    /// Internal key: `clickedEditor`
    pub has_clicked_editor: bool,
    /// Internal key: `clickedPractice`
    pub has_clicked_practice: bool,
    /// Internal key: `showedEditorGuide`
    pub seen_editor_guide: bool,
    /// Internal key: `showedLowDetailDialog`
    pub seen_ldm_dialog: bool,
    /// Internal key: `showedRateStarDialog`
    pub seen_rate_star_dialog: bool,
    /// Internal key: `hasRatedGame`
    pub has_rated_game: bool,
    /// Internal key: `binaryVersion`
    pub binary_version: i32,
    /// Respective internal keys: `practicePosX`, `praticePosY`
    pub practice_ui_pos: (f32, f32),
    /// Internal key: `practiceOpacity`
    pub practice_ui_opacity: f32,
    /// Internal key: `customFPSTarget`
    pub fps_target: f32,
    /// Music offset in milliseconds
    ///
    /// Internal key: `timeOffset`
    pub music_offset: i32,
    /// Internal keys: `dpad01`, `dpad02`, `dpad03`, `dpad04`, `dpad05`
    pub dpads: [GDPlatformerUI; 5],
    /// Internal key: `dpad_layout`
    pub dpad_layout: Option<GDPlatformerUI>,
    /// List of folder names for saved online levels. Folder names are stored in order, starting from folder 1. If an unnamed folder is found at index >= 1, it is stored as a `None`.
    ///
    /// Internal key: `GLM_18`
    pub saved_levels_foldernames: Vec<(i32, String)>,
    /// List of folder names for locally created levels (found in the editor tab). Folder names are stored in order, starting from folder 1. If an unnamed folder is found at index >= 1, it is stored as a `None`.
    ///
    /// Internal key: `GLM_19`
    pub local_levels_foldernames: Vec<(i32, String)>,
    /// Raw GLM_12 key encoding optimized for size. This key has a purpose that is assumed to be related to likes, though it is unknown.
    ///
    /// Internal key: `GLM_12`
    pub glm12_unknown: Vec<[i32; 4]>,
    /// Internal key: `GLM_23`
    pub glm23_unknown: i32,
    /// Lists that this player has favourited.
    ///
    /// Internal key: `GLM_22`
    pub favourite_lists: Vec<GDList>,
    /// Saved custom objects from the editor. Custom objects are indexed by some negative integer in the raw dictionary which serves an unclear purpose. Each element is stored as (index, objects).
    ///
    /// Internal key: `customObjectDict`
    pub custom_objects: Vec<(i32, Vec<GDObject>)>,
    /// Search filters for online levels
    ///
    /// Internal key: `GLM_08`
    pub search_filters: GDSearchFilters,
    /// Answer to `cod3breaker` in the vault of secrets
    ///
    /// Internal key: `secretNumber`
    pub secret_number: i32,
}

/// Configuration to do with the player's account and social settings
#[derive(Debug, Default, Clone)]
pub struct GDAccount {
    /// Internal key: `GJA_001`
    pub username: String,
    /// Password in plaintext (used in 2.1 and below)
    ///
    /// Internal key: `GJA_002`
    pub plaintext_password: Option<String>,
    /// Internal key: `GJA_003`
    pub account_id: i32,
    /// Appears to be unused
    ///
    /// Internal key: `GJA_004`
    pub session_id: Option<String>,
    /// Password encrypted with GJP2 encryption. This can be generated with [`crate::core::crypto::generate_gjp2_hexdigest`]
    ///
    /// Internal key: `GJA_005`
    pub hashed_password: Option<String>,
    /// List of creators' account IDs that this player follows
    ///
    /// Internal key: `GLM_06`
    pub following_creators: Vec<i32>,
    /// List of levels that the player has reported.
    ///
    /// Internal key: `GLM_14`
    pub reported_levels: Vec<i32>,
}

/// Temporary variables stored in the savefile that are expected to be overwritten in the future
#[derive(Debug, Default, Clone)]
pub struct GDCurrentValues {
    /// Levels that were played in the last session
    ///
    /// Internal key: `GLM_07`
    pub last_played_levels: Vec<i32>,
    /// The current daily level's TimelyID
    ///
    /// Internal key: `GLM_11`
    pub current_daily_level: i32,
    /// The current weekly level's TimelyID
    ///
    /// Internal key: `GLM_17`
    pub current_weekly_level: i32,
}

/// Song config
#[derive(Debug, Default, Clone)]
pub struct GDSongConfig {
    /// Presumably the songs that the user has stored or downloaded, but is unknown.
    ///
    /// Internal key: `MDLM_001`
    pub stored_songs: (), // Vec<SongInfoObject>: TODO
    /// Has something to do with song priority, but is unknown.
    ///
    /// Internal key: `MDLM_002`    
    pub song_priority: i32,
}

// ---- Supplementary structs ----

/* todo
struct descriptor for SongInfoObject:

1	ID	Integer	The ID of the song on Newgrounds
2	name	String	The name of the song
3	artistID	Integer	Newgrounds ArtistID
4	artistName	String	The name of the artist who made the song
5	size	Integer	Size of the song in MB, rounded to two decimal places
6	videoID	String	the Video ID for the songs YouTube Video
7	youtubeURL	String	The URL of the newgrounds user's youtube channel
8	isVerified	Bool	if the song artist is scouted on newgrounds
9	songPriority	Integer	priority over the song list
10	link	String	Link to the song's mp3
11	nongEnum	Integer	Type of NONG. 0 for none, 1 for NCS.
12	extraArtistIDs	Array[Integer]	IDs of extra artists, separated by .
13	new	Boolean	Whether the NEW icon shows up or not
14	newType	Integer	Type of NEW icon. 0 for Yellow, 1 for Blue
15	extraArtistNames	Array	Artist names in this format: {id},{name},{id},{name}

*/

repr_t!(
    strict TextureQuality: i32 {
        Auto = 0,
        Low = 1,
        Medium = 2,
        High = 3,
    } default Auto
);

repr_t!(
    strict Resolution: i32 {
        R640x480 = 1,     // 4:3
        R720x480 = 2,     // 3:2
        R720x576 = 3,     // 5:4
        R800x600 = 4,     // 4:3
        R1024x768 = 5,    // 4:3
        R1152x864 = 6,    // 4:3
        R1176x664 = 7,    // 147:83
        R1280x720 = 8,    // 16:9
        R1280x768 = 9,    // 5:3
        R1280x800 = 10,   // 16:10
        R1280x960 = 11,   // 4:3
        R1280x1024 = 12,  // 5:4
        R1360x768 = 13,   // 85:48
        R1366x768 = 14,   // 683:384
        R1440x900 = 15,   // 16:10
        R1600x900 = 16,   // 16:9
        R1600x1024 = 17,  // 25:16
        R1600x1200 = 18,  // 4:3
        R1680x1050 = 19,  // 16:10
        R1768x992 = 20,   // 221:124
        R1920x1080 = 21,  // 16:9
        R1920x1200 = 22,  // 16:10
        R1920x1440 = 23,  // 4:3
        R2048x1536 = 24,  // 4:3
        R2560x1440 = 25,  // 16:9
        R2560x1600 = 26,  // 16:10
        R3840x2160 = 27,  // 16:9
    } default R1920x1080
);

#[derive(Debug, Default, Clone)]
/// Platformer controls UI config
pub struct GDPlatformerUI {
    /// The width of the button hitbox
    pub width: i32,
    /// The height of the button hitbox
    pub height: i32,
    /// The scale of the buttons
    pub scale: f32,
    /// The button opacity (from 0 to 255)
    pub opacity: i32,
    /// The position of the buttons
    pub pos: (f32, f32),
    /// The ModeB checkbox
    pub mode_b: bool,
    /// The deadzone between the buttons
    pub deadzone: f32,
    /// The distance between the buttons
    pub radius: f32,
    /// The Snap checkbox
    pub snap: bool,
    /// The Split checkbox
    pub split: bool,
}

impl GDPlatformerUI {
    /// Parses a comma-separated list of values to this object
    pub fn from_str(s: &str) -> Self {
        let mut this = Self::default();
        let fns = &[
            Self::parse_width,
            Self::parse_height,
            Self::parse_scale,
            Self::parse_opacity,
            Self::parse_pos_x,
            Self::parse_pos_y,
            Self::parse_mode_b,
            Self::parse_deadzone,
            Self::parse_radius,
            Self::parse_snap,
            Self::parse_split,
        ];
        s.split(",")
            .into_iter()
            .enumerate()
            .for_each(|(idx, s)| (fns[idx])(&mut this, s));

        this
    }

    fn parse_width(&mut self, s: &str) {
        self.width = s.parse::<i32>().unwrap();
    }
    fn parse_height(&mut self, s: &str) {
        self.height = s.parse::<i32>().unwrap();
    }
    fn parse_scale(&mut self, s: &str) {
        self.scale = s.parse::<f32>().unwrap();
    }
    fn parse_opacity(&mut self, s: &str) {
        self.opacity = s.parse::<i32>().unwrap();
    }
    fn parse_pos_x(&mut self, s: &str) {
        self.pos.0 = s.parse::<f32>().unwrap();
    }
    fn parse_pos_y(&mut self, s: &str) {
        self.pos.1 = s.parse::<f32>().unwrap();
    }
    fn parse_mode_b(&mut self, s: &str) {
        self.mode_b = s.parse::<i32>().unwrap() == 1;
    }
    fn parse_deadzone(&mut self, s: &str) {
        self.deadzone = s.parse::<f32>().unwrap();
    }
    fn parse_radius(&mut self, s: &str) {
        self.radius = s.parse::<f32>().unwrap();
    }
    fn parse_snap(&mut self, s: &str) {
        self.snap = s.parse::<i32>().unwrap() == 1;
    }
    fn parse_split(&mut self, s: &str) {
        self.split = s.parse::<i32>().unwrap() == 1;
    }
}

#[derive(Debug, Clone, PartialEq, Default)]
/// Search filter state for online levels
pub struct GDSearchFilters {
    /// The majority of search filters. Contains all search boolean flags by which online levels may be searched.
    pub boolean_flags: GDSearchFilterFlags,
    /// Optionally enabled song ID filter. Filters out all levels that do not have this song ID if given.
    pub song: Option<i32>,
    /// Values that are unaccounted for. This dictionary should be empty when parsing a vanilla savefile.
    pub other: HashMap<String, Value>,
}

bitflags! {
    #[derive(Debug, Copy, Clone, PartialEq, Default, Eq, Hash)]
    #[must_use]
    /// Filters for searching online levels
    pub struct GDSearchFilterFlags: u32 {
        /// Internal key: `Diff0`
        const DifficultyNA       = 1;
        /// Internal key: `Diff1`
        const DifficultyAuto     = 1 << 1;
        /// Internal key: `Diff2`
        const DifficultyEasy     = 1 << 2;
        /// Internal key: `Diff3`
        const DifficultyNormal   = 1 << 3;
        /// Internal key: `Diff4`
        const DifficultyHard     = 1 << 4;
        /// Internal key: `Diff5`
        const DifficultyHarder   = 1 << 5;
        /// Internal key: `Diff6`
        const DifficultyInsane   = 1 << 6;
        /// Internal key: `Diff7`
        const DifficultyDemon    = 1 << 7;
        /// Tiny levels
        ///
        /// Internal key: `Len0`
        const LengthTiny         = 1 << 8;
        /// Small levels
        ///
        /// Internal key: `Len1`
        const LengthSmall        = 1 << 9;
        /// Medium levels
        ///
        /// Internal key: `Len2`
        const LengthMedium       = 1 << 10;
        /// Long levels
        ///
        /// Internal key: `Len3`
        const LengthLong         = 1 << 11;
        /// XL levels
        ///
        /// Internal key: `Len4`
        const LengthXL           = 1 << 12;
        /// Internal key: `demon_filter`
        const DemonFilter       = 1 << 13;
        /// Platformer levels
        ///
        /// Internal key: `Len5`
        const Platformer         = 1 << 14;
        /// Star-rated levels
        ///
        /// Internal key: `star_filter`
        const HasStars           = 1 << 15;
        /// Internal key: `customsong_filter`
        const CustomSongFilter   = 1 << 16;
        /// Internal key: `mythic_filter`
        const IsMythic           = 1 << 18;
        /// Internal key: `enable_songFilter`
        const SongFilterEnabled  = 1 << 19;
        /// Works only for rated levels.
        ///
        /// Internal key: `uncompleted_filter`
        const IsUncompleted      = 1 << 20;
        /// Internal key: `completed_filter`
        const IsCompleted        = 1 << 21;
        /// Internal key: `featured_filter`
        const IsFeatured         = 1 << 22;
        /// Internal key: `original_filter`
        const IsOriginal         = 1 << 23;
        /// Internal key: `twoP_filter`
        const TwoPlayer          = 1 << 24;
        /// Internal key: `nostar_filter`
        const NoStars            = 1 << 25;
        /// Internal key: `coin_filter`
        const HasCoins           = 1 << 26;
        /// Levels from creators that the player follows.
        ///
        /// Internal key: `follow_filter`
        const FollowedCreator      = 1 << 27;
        /// Levels from friends of this player.
        ///
        /// Internal key: `friend_filter`
        const Friends      = 1 << 28;
        /// Levels with a rating of epic.
        ///
        /// Internal key: `epic_filter`
        const RatingEpic        = 1 << 29;
        /// Internal key: `legendary_filter`
        const RatingLegendary   = 1 << 30;
    }
}

impl GDSearchFilters {
    /// Parses a dictionary from CCGameManager to search filters. This function will return `None` if the filter
    /// dictionary is malformed, which can be due to of the following:
    /// * Any value is not stored as a string
    /// * The value for the `song_filter` key is not a numerical string
    ///
    /// Values not accounted for are still parsed, however they are left as-is and stored in `self.other`.
    pub fn from_dict(d: &Dictionary) -> Option<Self> {
        let mut this = Self::default();
        for (k, v) in d.iter() {
            let bool_value = v.as_string()? == "1";
            match k.as_str() {
                "Diff0" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyNA, bool_value),
                "Diff1" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyAuto, bool_value),
                "Diff2" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyEasy, bool_value),
                "Diff3" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyNormal, bool_value),
                "Diff4" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyHard, bool_value),
                "Diff5" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyHarder, bool_value),
                "Diff6" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyInsane, bool_value),
                "Diff7" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DifficultyDemon, bool_value),
                "Len0" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::LengthTiny, bool_value),
                "Len1" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::LengthSmall, bool_value),
                "Len2" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::LengthMedium, bool_value),
                "Len3" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::LengthLong, bool_value),
                "Len4" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::LengthXL, bool_value),
                "demon_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::DemonFilter, bool_value),
                "Len5" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::Platformer, bool_value),
                "star_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::HasStars, bool_value),
                "customsong_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::CustomSongFilter, bool_value),
                "mythic_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::IsMythic, bool_value),
                "enable_songFilter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::SongFilterEnabled, bool_value),
                "uncompleted_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::IsUncompleted, bool_value),
                "completed_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::IsCompleted, bool_value),
                "featured_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::IsFeatured, bool_value),
                "original_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::IsOriginal, bool_value),
                "twoP_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::TwoPlayer, bool_value),
                "nostar_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::NoStars, bool_value),
                "coin_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::HasCoins, bool_value),
                "follow_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::FollowedCreator, bool_value),
                "friend_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::Friends, bool_value),
                "epic_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::RatingEpic, bool_value),
                "legendary_filter" => this
                    .boolean_flags
                    .set(GDSearchFilterFlags::RatingLegendary, bool_value),
                "song_filter" => this.song = Some(v.as_string()?.parse::<i32>().ok()?),
                _ => {
                    this.other.insert(k.clone(), v.clone());
                }
            }
        }

        Some(this)
    }
}

impl GDStatistics {
    /// Returns the reported progress value on this specific vanilla achievement. See [`AchievementProgress`].
    #[inline]
    pub fn get_vanilla_achievement(&self, a: GDAchievement) -> AchievementProgress {
        // subtract one from index because of the way that achievement repr was created
        self.achievements[a as i32 as usize - 1]
    }

    /// Returns the reported progress value on this specific achievement. The given identifier is first parsed
    /// as a vanilla achievement. If the achievement is a vanilla achievement, [`Self::get_vanilla_achievement`]
    /// is used to retrieve its value. Otherwise, it is looked up in `self.custom_achievements`.
    /// See [`AchievementProgress`].
    #[inline]
    pub fn get_achievement_by_ident(&self, ident: &str) -> Option<AchievementProgress> {
        match GDAchievement::parse_str(ident) {
            Some(a) => Some(self.get_vanilla_achievement(a)),
            None => {
                // this is not a vanilla achievement; lookup in custom achievements
                self.custom_achievements.get(ident).copied()
            }
        }
    }

    /// Set an achievement's progress value. See [`AchievementProgress`].
    #[inline]
    pub fn set_vanilla_achievement(&mut self, a: GDAchievement, value: AchievementProgress) {
        self.achievements[a as i32 as usize - 1] = value;
    }

    /// Set an achievement's progress value. The given identifier is first parsed
    /// as a vanilla achievement. If the achievement is a vanilla achievement, [`Self::get_vanilla_achievement`]
    /// is used to set its value. Otherwise, it is looked up in `self.custom_achievements`.
    /// See [`AchievementProgress`].
    #[inline]
    pub fn set_achievement_by_ident(&mut self, ident: &str, value: AchievementProgress) {
        match GDAchievement::parse_str(ident) {
            Some(a) => self.set_vanilla_achievement(a, value),
            None => {
                // this is not a vanilla achievement; lookup in custom achievements
                let _ = self.custom_achievements.insert(ident.to_string(), value);
            }
        }
    }
}