├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── conv ├── Cargo.toml └── src │ └── main.rs └── src ├── collection.rs ├── lib.rs ├── listing.rs ├── replay.rs └── score.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | .idea 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | TODO: 3 | 4 | - Support replays with the "Target Practice" mod enabled. 5 | 6 | # 0.2.1 7 | 8 | - Added support for a beatmap breaking change in `20250107`, which changes `Double` to `Float`. 9 | 10 | # 0.2 11 | 12 | - Added support for osu! binary files from `20191106` up to at least `20201017`. 13 | - Renamed `mysterious_int` to `user_permissions`. 14 | - Removed the `Hash` and `PartialEq` impls on `Beatmap` that only took the beatmap hash and beatmap 15 | ID into account. 16 | - Fields of `Replay` no longer change depending on features. Instead the `replay_data` field is 17 | always available, but is only `Some` when the `compression` feature is enabled. The 18 | `raw_replay_data` field is always available, instead. 19 | - `PartialEq` is now implemented everywhere. 20 | - Moved from `failure` to `std::error::Error`. 21 | - Made the unintentionally exposed constants `BREAKING_CHANGE` and `DEFAULT_COMPRESSION_LEVEL` 22 | private. 23 | 24 | # 0.1 25 | 26 | - Support for reading and writing `osu!.db`, `collection.db`, `scores.db` and `*.osr` files. 27 | - Support for osu! binary files up to at least `20181221`. 28 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.33" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "a1fd36ffbb1fb7c834eac128ea8d0e310c5aeb635548f9d58861e1308d46e71c" 10 | 11 | [[package]] 12 | name = "autocfg" 13 | version = "1.0.1" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 16 | 17 | [[package]] 18 | name = "base64" 19 | version = "0.12.3" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" 22 | 23 | [[package]] 24 | name = "bitflags" 25 | version = "1.2.1" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 28 | 29 | [[package]] 30 | name = "byteorder" 31 | version = "1.3.4" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" 34 | 35 | [[package]] 36 | name = "cc" 37 | version = "1.0.61" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "ed67cbde08356238e75fc4656be4749481eeffb09e19f320a25237d5221c985d" 40 | 41 | [[package]] 42 | name = "chrono" 43 | version = "0.4.19" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 46 | dependencies = [ 47 | "libc", 48 | "num-integer", 49 | "num-traits", 50 | "serde", 51 | "time", 52 | "winapi", 53 | ] 54 | 55 | [[package]] 56 | name = "conv" 57 | version = "0.2.0" 58 | dependencies = [ 59 | "anyhow", 60 | "chrono", 61 | "fxhash", 62 | "osu-db", 63 | "ron", 64 | "serde", 65 | "serde_json", 66 | ] 67 | 68 | [[package]] 69 | name = "fxhash" 70 | version = "0.2.1" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" 73 | dependencies = [ 74 | "byteorder", 75 | ] 76 | 77 | [[package]] 78 | name = "itoa" 79 | version = "0.4.6" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" 82 | 83 | [[package]] 84 | name = "libc" 85 | version = "0.2.80" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" 88 | 89 | [[package]] 90 | name = "lzma-sys" 91 | version = "0.1.17" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "bdb4b7c3eddad11d3af9e86c487607d2d2442d185d848575365c4856ba96d619" 94 | dependencies = [ 95 | "cc", 96 | "libc", 97 | "pkg-config", 98 | ] 99 | 100 | [[package]] 101 | name = "memchr" 102 | version = "2.3.4" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 105 | 106 | [[package]] 107 | name = "minimal-lexical" 108 | version = "0.2.1" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 111 | 112 | [[package]] 113 | name = "nom" 114 | version = "7.1.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" 117 | dependencies = [ 118 | "memchr", 119 | "minimal-lexical", 120 | "version_check", 121 | ] 122 | 123 | [[package]] 124 | name = "num-integer" 125 | version = "0.1.44" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 128 | dependencies = [ 129 | "autocfg", 130 | "num-traits", 131 | ] 132 | 133 | [[package]] 134 | name = "num-traits" 135 | version = "0.2.14" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 138 | dependencies = [ 139 | "autocfg", 140 | ] 141 | 142 | [[package]] 143 | name = "osu-db" 144 | version = "0.2.1" 145 | dependencies = [ 146 | "chrono", 147 | "nom", 148 | "serde", 149 | "serde_derive", 150 | "xz2", 151 | ] 152 | 153 | [[package]] 154 | name = "pkg-config" 155 | version = "0.3.19" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" 158 | 159 | [[package]] 160 | name = "proc-macro2" 161 | version = "1.0.24" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" 164 | dependencies = [ 165 | "unicode-xid", 166 | ] 167 | 168 | [[package]] 169 | name = "quote" 170 | version = "1.0.7" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" 173 | dependencies = [ 174 | "proc-macro2", 175 | ] 176 | 177 | [[package]] 178 | name = "ron" 179 | version = "0.6.2" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "f8a58080b7bb83b2ea28c3b7a9a994fd5e310330b7c8ca5258d99b98128ecfe4" 182 | dependencies = [ 183 | "base64", 184 | "bitflags", 185 | "serde", 186 | ] 187 | 188 | [[package]] 189 | name = "ryu" 190 | version = "1.0.5" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 193 | 194 | [[package]] 195 | name = "serde" 196 | version = "1.0.117" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" 199 | dependencies = [ 200 | "serde_derive", 201 | ] 202 | 203 | [[package]] 204 | name = "serde_derive" 205 | version = "1.0.117" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e" 208 | dependencies = [ 209 | "proc-macro2", 210 | "quote", 211 | "syn", 212 | ] 213 | 214 | [[package]] 215 | name = "serde_json" 216 | version = "1.0.59" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "dcac07dbffa1c65e7f816ab9eba78eb142c6d44410f4eeba1e26e4f5dfa56b95" 219 | dependencies = [ 220 | "itoa", 221 | "ryu", 222 | "serde", 223 | ] 224 | 225 | [[package]] 226 | name = "syn" 227 | version = "1.0.48" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "cc371affeffc477f42a221a1e4297aedcea33d47d19b61455588bd9d8f6b19ac" 230 | dependencies = [ 231 | "proc-macro2", 232 | "quote", 233 | "unicode-xid", 234 | ] 235 | 236 | [[package]] 237 | name = "time" 238 | version = "0.1.44" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 241 | dependencies = [ 242 | "libc", 243 | "wasi", 244 | "winapi", 245 | ] 246 | 247 | [[package]] 248 | name = "unicode-xid" 249 | version = "0.2.1" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 252 | 253 | [[package]] 254 | name = "version_check" 255 | version = "0.9.3" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 258 | 259 | [[package]] 260 | name = "wasi" 261 | version = "0.10.0+wasi-snapshot-preview1" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" 264 | 265 | [[package]] 266 | name = "winapi" 267 | version = "0.3.9" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 270 | dependencies = [ 271 | "winapi-i686-pc-windows-gnu", 272 | "winapi-x86_64-pc-windows-gnu", 273 | ] 274 | 275 | [[package]] 276 | name = "winapi-i686-pc-windows-gnu" 277 | version = "0.4.0" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 280 | 281 | [[package]] 282 | name = "winapi-x86_64-pc-windows-gnu" 283 | version = "0.4.0" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 286 | 287 | [[package]] 288 | name = "xz2" 289 | version = "0.1.6" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "c179869f34fc7c01830d3ce7ea2086bc3a07e0d35289b667d0a8bf910258926c" 292 | dependencies = [ 293 | "lzma-sys", 294 | ] 295 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "osu-db" 3 | version = "0.2.1" 4 | authors = ["negamartin"] 5 | edition = "2018" 6 | 7 | # Crates.io package info 8 | description = "Reading and writing of osu! binary files: `osu!.db`, `collection.db`, `scores.db` and `.osr` replay files." 9 | readme = "README.md" 10 | repository = "https://github.com/negamartin/osu-db" 11 | license = "Unlicense" 12 | keywords = ["osu"] 13 | categories = ["encoding", "parser-implementations"] 14 | include = ["Cargo.toml", "src", "LICENSE"] 15 | 16 | [workspace] 17 | members = ["conv"] 18 | 19 | [dependencies] 20 | nom = "7" 21 | chrono = "0.4" 22 | 23 | serde = { version = "1", optional = true } 24 | serde_derive = { version = "1", optional = true } 25 | 26 | xz2 = { version = "0.1", optional = true } 27 | 28 | [features] 29 | default = ["compression"] 30 | # Provides serde integration. 31 | ser-de = ["serde", "serde_derive", "chrono/serde"] 32 | # Provides replay data decompression and further parsing. 33 | compression = ["xz2"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osu-db 2 | 3 | `osu-db` is an osu! binary file format encoder/decoder, providing support for 4 | loading, modifying and saving the following osu! file formats: 5 | 6 | - `osu!.db`: The main beatmap information cache osu! uses. 7 | - `collection.db`: A list of collections and the beatmaps they contain. 8 | - `scores.db`: Overview of all user scores. 9 | - `.osr` files: Individual in-depth score data of a single replay. 10 | 11 | To use, simply add this line to your `Cargo.toml`: 12 | 13 | ```toml 14 | osu-db = "0.2" 15 | ``` 16 | 17 | After that you will want to use the different load/save functions on the 18 | `Listing` (cached beatmap database), `ScoreList` (summary of all player scores), 19 | `CollectionList` (in-game beatmap collections) or `Replay` (a single in-depth 20 | standalone replay file). 21 | 22 | For example, to change all of your osu!mania grades to `SS+`: 23 | 24 | ```rust 25 | use osu_db::listing::{Listing, Grade}; 26 | 27 | // Load the listing to memory 28 | let mut listing = Listing::from_file("osu!.db").unwrap(); 29 | 30 | // Modify listing in-place 31 | for beatmap in listing.beatmaps.iter_mut() { 32 | beatmap.mania_grade = Grade::SSPlus; 33 | } 34 | 35 | // Save back to disk 36 | listing.save("osu!.db").unwrap(); 37 | ``` 38 | 39 | More details in the crate documentation. 40 | 41 | `osu-db` has been tested to support `osu!stable` binaries of at least version 42 | `20211103`, and will probably support newer binaries. 43 | Old binaries are supported, as old as 2014, although these are no longer tested 44 | and no guarantees are made. 45 | -------------------------------------------------------------------------------- /conv/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "conv" 3 | version = "0.2.0" 4 | authors = ["negamartin"] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | osu-db = { path = "..", features = ["ser-de"] } 9 | 10 | anyhow = "1" 11 | serde = "1" 12 | ron = "0.6" 13 | serde_json = "1" 14 | fxhash = "0.2" 15 | chrono = "0.4" 16 | -------------------------------------------------------------------------------- /conv/src/main.rs: -------------------------------------------------------------------------------- 1 | //! A simple command-line tool for decoding and encoding osu binary files to plaintext. 2 | //! 3 | //! **NOTE**: Currently encoding from plaintext is unsupported by osu-db. 4 | 5 | use anyhow::{anyhow, bail, ensure, Context, Result}; 6 | use chrono::{DateTime, Utc}; 7 | use fxhash::{FxHashMap as HashMap, FxHashSet as HashSet}; 8 | use osu_db::{ 9 | collection::Collection, 10 | listing::{Beatmap, RankedStatus}, 11 | replay::Action, 12 | score::BeatmapScores, 13 | CollectionList, Listing, Replay, ScoreList, 14 | }; 15 | use std::{ 16 | cmp::{self, Reverse}, 17 | env, 18 | ffi::OsStr, 19 | fmt::{self, Write}, 20 | fs, 21 | hash::{Hash, Hasher}, 22 | mem, 23 | path::{Path, PathBuf}, 24 | }; 25 | 26 | const HELP_MSG: &'static str = r#" 27 | Usage: osuconv [OPTIONS] 28 | 29 | Available options: 30 | -i --in An input file with an optional format. At least one input must be supplied. 31 | -i --out An output file with an optional format. At most one output can be supplied. 32 | -h --help Print this message. 33 | -u --union Deduplicate items that appear in more than one file. This is the default. 34 | -n --intersection Remove any items that do not appear in all input files. 35 | -d --difference Remove any items in the first file that appear in the other files. 36 | 37 | By default the output filename is the same as the input with the extension changed to the target format. 38 | 39 | `osuconv` will try to guess input and output formats, but for finer control you can explicitly specify formats 40 | after file paths, separated by a '?' character. 41 | Available formats: 42 | `listing`, `collection`, `score`, `replay`: Type of osu! file. 43 | `bin`, `ron`, `json`: Encoding format. 44 | Note that encodings and filetypes are not exclusive (eg. `osu!.db?listing?bin` is a valid path). 45 | "#; 46 | 47 | /// Rank a beatmap by correctness, such that if there is a collision one can be chosen over the 48 | /// other. 49 | #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord)] 50 | struct BmCorrectness<'a> { 51 | folder_name: PreferPresent<'a>, 52 | hash: PreferPresent<'a>, 53 | file_name: PreferPresent<'a>, 54 | beatmap_id: PreferPositive, 55 | beatmapset_id: PreferPositive, 56 | difficulty_name: PreferPresent<'a>, 57 | ranked_status: PreferRanked, 58 | audio: PreferPresent<'a>, 59 | last_online_check: PreferLarger>, 60 | last_played: PreferLarger>>, 61 | } 62 | impl<'a> BmCorrectness<'a> { 63 | fn new(bm: &'a Beatmap) -> Self { 64 | Self { 65 | folder_name: PreferPresent(&bm.folder_name), 66 | hash: PreferPresent(&bm.hash), 67 | file_name: PreferPresent(&bm.file_name), 68 | beatmap_id: PreferPositive(bm.beatmap_id), 69 | beatmapset_id: PreferPositive(bm.beatmapset_id), 70 | difficulty_name: PreferPresent(&bm.difficulty_name), 71 | ranked_status: PreferRanked(bm.status), 72 | audio: PreferPresent(&bm.audio), 73 | last_online_check: PreferLarger(bm.last_online_check), 74 | last_played: PreferLarger(bm.last_played), 75 | } 76 | } 77 | } 78 | 79 | macro_rules! impl_ord_by_key { 80 | { 81 | impl[$($bounds:tt)*] * for $ty:ty { 82 | fn key($val:ident) $key:expr 83 | } 84 | } => { 85 | impl<$($bounds)*> Ord for $ty { 86 | fn cmp(&self, rhs: &Self) -> cmp::Ordering { 87 | let l = { 88 | let $val = self; 89 | $key 90 | }; 91 | let r = { 92 | let $val = rhs; 93 | $key 94 | }; 95 | l.cmp(&r) 96 | } 97 | } 98 | impl<$($bounds)*> PartialOrd for $ty { 99 | fn partial_cmp(&self, rhs: &Self) -> Option { 100 | Some(self.cmp(rhs)) 101 | } 102 | } 103 | impl<$($bounds)*> PartialEq for $ty { 104 | fn eq(&self, rhs: &Self) -> bool { 105 | self.cmp(rhs) == cmp::Ordering::Equal 106 | } 107 | } 108 | impl<$($bounds)*> Eq for $ty {} 109 | }; 110 | } 111 | 112 | #[derive(Debug, Clone)] 113 | struct PreferRanked(RankedStatus); 114 | impl_ord_by_key! { 115 | impl[] * for PreferRanked { 116 | fn key(ranked) { 117 | use osu_db::listing::RankedStatus::*; 118 | match ranked.0 { 119 | Unknown => 0, 120 | Unsubmitted => 1, 121 | PendingWipGraveyard => 1, 122 | Approved => 2, 123 | Qualified => 2, 124 | Loved => 3, 125 | Ranked => 4, 126 | } 127 | } 128 | } 129 | } 130 | impl From for PreferRanked { 131 | fn from(inner: RankedStatus) -> Self { 132 | Self(inner) 133 | } 134 | } 135 | 136 | #[derive(Debug, Clone)] 137 | struct PreferPositive(T); 138 | impl_ord_by_key! { 139 | impl[T: PartialOrd+Default] * for PreferPositive { 140 | fn key(p) { 141 | (p.0 >= T::default()) as u8 142 | } 143 | } 144 | } 145 | impl From for PreferPositive { 146 | fn from(inner: T) -> Self { 147 | Self(inner) 148 | } 149 | } 150 | 151 | #[derive(Clone)] 152 | struct PreferPresent<'a>(&'a Option); 153 | impl fmt::Debug for PreferPresent<'_> { 154 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 155 | write!( 156 | f, 157 | "PreferPresent({})", 158 | if self.0.is_some() { 159 | "Present" 160 | } else { 161 | "NotPresent" 162 | } 163 | ) 164 | } 165 | } 166 | impl_ord_by_key! { 167 | impl[] * for PreferPresent<'_> { 168 | fn key(p) { 169 | p.0.is_some() as u8 170 | } 171 | } 172 | } 173 | impl<'a> From<&'a Option> for PreferPresent<'a> { 174 | fn from(inner: &'a Option) -> Self { 175 | Self(inner) 176 | } 177 | } 178 | 179 | #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] 180 | struct PreferSmaller(Reverse); 181 | impl fmt::Debug for PreferSmaller { 182 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 183 | write!(f, "PreferSmaller(")?; 184 | fmt::Debug::fmt(&(self.0).0, f)?; 185 | write!(f, ")")?; 186 | Ok(()) 187 | } 188 | } 189 | impl From for PreferSmaller { 190 | fn from(inner: T) -> Self { 191 | Self(Reverse(inner)) 192 | } 193 | } 194 | 195 | #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] 196 | struct PreferLarger(T); 197 | impl fmt::Debug for PreferLarger { 198 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 199 | write!(f, "PreferLarger(")?; 200 | fmt::Debug::fmt(&self.0, f)?; 201 | write!(f, ")")?; 202 | Ok(()) 203 | } 204 | } 205 | impl From for PreferLarger { 206 | fn from(inner: T) -> Self { 207 | Self(inner) 208 | } 209 | } 210 | 211 | #[derive(Debug, Clone)] 212 | enum InMemory { 213 | Listing(Listing), 214 | Collections(CollectionList), 215 | Scores(ScoreList), 216 | Replay(Replay), 217 | } 218 | impl InMemory { 219 | fn file_type(&self) -> FileType { 220 | match self { 221 | InMemory::Listing(_) => FileType::Listing, 222 | InMemory::Collections(_) => FileType::Collections, 223 | InMemory::Scores(_) => FileType::Scores, 224 | InMemory::Replay(_) => FileType::Replay, 225 | } 226 | } 227 | } 228 | 229 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 230 | enum FileType { 231 | Listing, 232 | Collections, 233 | Scores, 234 | Replay, 235 | } 236 | 237 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 238 | enum FileFormat { 239 | Bin, 240 | Ron, 241 | Json, 242 | } 243 | 244 | fn read(ty: FileType, fmt: FileFormat, bytes: &[u8]) -> Result { 245 | macro_rules! file_types { 246 | ($($name:ident [$ty:ty]),*) => {{ 247 | match ty { 248 | $( 249 | FileType::$name => InMemory::$name(match fmt { 250 | FileFormat::Bin => { 251 | <$ty>::from_bytes(bytes)? 252 | } 253 | FileFormat::Ron => { 254 | ron::de::from_bytes(bytes)? 255 | } 256 | FileFormat::Json => { 257 | serde_json::from_reader(bytes)? 258 | } 259 | }), 260 | )* 261 | } 262 | }} 263 | } 264 | Ok(file_types!( 265 | Listing[Listing], 266 | Collections[CollectionList], 267 | Scores[ScoreList], 268 | Replay[Replay] 269 | )) 270 | } 271 | 272 | fn merge(merge_op: MergeOp, inputs: impl IntoIterator) -> Result { 273 | let inputs = inputs.into_iter().collect::>(); 274 | let mut ty = None; 275 | ensure!( 276 | inputs 277 | .iter() 278 | .all(|in_mem| { in_mem.file_type() == *ty.get_or_insert(in_mem.file_type()) }), 279 | "cannot merge different types of files" 280 | ); 281 | let ty = ty.ok_or(anyhow!("zero inputs"))?; 282 | if inputs.len() > 1 { 283 | println!("merging {} {:?} files", inputs.len(), ty); 284 | } 285 | macro_rules! prepare { 286 | ($name:ident => |$val:ident| $get_list:expr) => {{ 287 | let mut inputs = inputs 288 | .into_iter() 289 | .map(|in_mem| match in_mem { 290 | InMemory::$name(d) => d, 291 | _ => unreachable!(), 292 | }) 293 | .collect::>(); 294 | let tmp = { 295 | let $val = &mut inputs[0]; 296 | mem::replace(&mut $get_list, Default::default()) 297 | }; 298 | let out = inputs[0].clone(); 299 | { 300 | let $val = &mut inputs[0]; 301 | let refmut = &mut $get_list; 302 | *refmut = tmp; 303 | } 304 | (inputs, out) 305 | }}; 306 | } 307 | let merge_meta_add = |idx| match merge_op { 308 | MergeOp::Or => 1, 309 | MergeOp::And => 1, 310 | MergeOp::Diff => { 311 | if idx == 0 { 312 | 0 313 | } else { 314 | 1 315 | } 316 | } 317 | }; 318 | fn merge_filter( 319 | (merge_op, listing_count): (MergeOp, usize), 320 | (meta, item): (i32, T), 321 | ) -> Option { 322 | let include = match merge_op { 323 | MergeOp::Or => true, 324 | MergeOp::And => meta as usize == listing_count, 325 | MergeOp::Diff => meta == 0, 326 | }; 327 | if include { 328 | Some(item) 329 | } else { 330 | None 331 | } 332 | } 333 | let merge_ctx = (merge_op, inputs.len()); 334 | let out = match ty { 335 | FileType::Listing => { 336 | let (listings, mut out) = prepare!(Listing => |listing| listing.beatmaps); 337 | out.folder_count = 0; 338 | let mut by_hash = HashMap::default(); 339 | let mut no_hash = Vec::new(); 340 | let mut folders = HashSet::default(); 341 | for (i, listing) in listings.into_iter().enumerate() { 342 | println!("processing listing {}", i); 343 | println!(" version: {}", listing.version); 344 | println!(" beatmaps: {}", listing.beatmaps.len()); 345 | println!(" folder_count: {}", listing.folder_count); 346 | println!( 347 | " player_name: '{}'", 348 | listing.player_name.unwrap_or_default() 349 | ); 350 | for bm in listing.beatmaps { 351 | if let Some(fname) = &bm.folder_name { 352 | folders.insert(fname.clone()); 353 | } else { 354 | println!( 355 | " WARNING: beatmap {:?} has no folder name", 356 | (&bm.title_ascii, &bm.difficulty_name, &bm.file_name) 357 | ); 358 | } 359 | if let Some(hash) = &bm.hash { 360 | if let Some((meta, old_bm)) = by_hash.get_mut(&hash[..]) { 361 | let new = BmCorrectness::new(&bm); 362 | let old = BmCorrectness::new(old_bm); 363 | let replace = new > old; 364 | println!( 365 | "found beatmaps with the same hash: new {} old -> {}\n{:?}\n{:?}\n", 366 | match new.cmp(&old) { 367 | cmp::Ordering::Less => "<", 368 | cmp::Ordering::Equal => "=", 369 | cmp::Ordering::Greater => ">", 370 | }, 371 | if replace { 372 | "replacing" 373 | } else { 374 | "not replacing" 375 | }, 376 | new, 377 | old 378 | ); 379 | if replace { 380 | *old_bm = bm; 381 | } 382 | *meta += merge_meta_add(i); 383 | } else { 384 | let meta = merge_meta_add(i); 385 | by_hash.insert(hash.clone(), (meta, bm)); 386 | } 387 | } else { 388 | println!( 389 | " WARNING: beatmap {:?} has no hash", 390 | (&bm.title_ascii, &bm.difficulty_name, &bm.folder_name) 391 | ); 392 | no_hash.push(bm); 393 | } 394 | } 395 | } 396 | out.beatmaps = by_hash 397 | .drain() 398 | .filter_map(|(_hash, bm)| merge_filter(merge_ctx, bm)) 399 | .chain(no_hash.into_iter()) 400 | .collect(); 401 | out.folder_count = folders.len() as u32; 402 | println!( 403 | "output listing has {} beatmaps and {} folders", 404 | out.beatmaps.len(), 405 | out.folder_count 406 | ); 407 | InMemory::Listing(out) 408 | } 409 | FileType::Collections => { 410 | let (lists, mut out) = prepare!(Collections => |list| list.collections); 411 | let mut by_name: HashMap)> = HashMap::default(); 412 | //Merge everything into hashmaps 413 | for (i, list) in lists.into_iter().enumerate() { 414 | println!("processing collection listing {}", i); 415 | println!(" version: {}", list.version); 416 | println!(" collections: {}", list.collections.len()); 417 | for (j, collection) in list.collections.into_iter().enumerate() { 418 | if let Some(collection_name) = collection.name { 419 | println!(" processing collection {} '{}'", j, collection_name); 420 | println!(" beatmaps: {}", collection.beatmap_hashes.len()); 421 | let (collection_meta, out_collection) = 422 | by_name.entry(collection_name).or_default(); 423 | *collection_meta += merge_meta_add(i); 424 | for (k, bm_hash) in collection.beatmap_hashes.into_iter().enumerate() { 425 | if let Some(bm_hash) = bm_hash { 426 | *out_collection.entry(bm_hash).or_default() += merge_meta_add(i); 427 | } else { 428 | println!( 429 | " WARNING: beatmap {} in collection has no hash?? skipping", 430 | k 431 | ); 432 | } 433 | } 434 | } else { 435 | println!(" WARNING: collection {} has no name, skipping", j); 436 | } 437 | } 438 | } 439 | //Rebuild collection list 440 | for (name, (collection_meta, collection)) in by_name { 441 | let out_collection = Collection { 442 | name: Some(name), 443 | beatmap_hashes: collection 444 | .into_iter() 445 | .filter_map(|(hash, meta)| merge_filter(merge_ctx, (meta, Some(hash)))) 446 | .collect(), 447 | }; 448 | if !out_collection.beatmap_hashes.is_empty() 449 | || merge_filter(merge_ctx, (collection_meta, ())).is_some() 450 | { 451 | out.collections.push(out_collection); 452 | } 453 | } 454 | //Print info 455 | println!( 456 | "output collection list has {} collections", 457 | out.collections.len() 458 | ); 459 | for collection in out.collections.iter() { 460 | println!( 461 | " '{}': {} beatmaps", 462 | collection.name.as_deref().unwrap_or_default(), 463 | collection.beatmap_hashes.len() 464 | ); 465 | } 466 | InMemory::Collections(out) 467 | } 468 | FileType::Scores => { 469 | let (lists, mut out) = prepare!(Scores => |list| list.beatmaps); 470 | //Merge replays into a hashmap of hashsets 471 | let mut bms_by_hash: HashMap)> = 472 | HashMap::default(); 473 | //Hashes all NaNs as equal and all zeroes as equal 474 | fn hash_f32(f: f32, h: &mut H) { 475 | if f.is_nan() { 476 | h.write_u32(0xffffffff); 477 | } else if f == 0. { 478 | h.write_u32(0); 479 | } else { 480 | h.write_u32(f.to_bits()); 481 | } 482 | } 483 | //Compares all NaNs as equal 484 | fn eq_f32(lhs: f32, rhs: f32) -> bool { 485 | if lhs.is_nan() && rhs.is_nan() { 486 | true 487 | } else { 488 | lhs == rhs 489 | } 490 | } 491 | struct ActionsEq<'a>(&'a Option>); 492 | impl Hash for ActionsEq<'_> { 493 | fn hash(&self, h: &mut H) { 494 | h.write_u8(self.0.is_some() as u8); 495 | if let Some(actions) = self.0 { 496 | h.write_usize(actions.len()); 497 | for action in actions { 498 | let Action { delta, x, y, z } = action; 499 | h.write_i64(*delta); 500 | hash_f32(*x, h); 501 | hash_f32(*y, h); 502 | hash_f32(*z, h); 503 | } 504 | } 505 | } 506 | } 507 | impl PartialEq for ActionsEq<'_> { 508 | fn eq(&self, rhs: &Self) -> bool { 509 | match (self.0.as_deref(), rhs.0.as_deref()) { 510 | (Some(lhs), Some(rhs)) => { 511 | if lhs.len() != rhs.len() { 512 | return false; 513 | } 514 | lhs.iter().zip(rhs.iter()).all(|(lhs, rhs)| { 515 | lhs.delta == rhs.delta 516 | && eq_f32(lhs.x, rhs.x) 517 | && eq_f32(lhs.y, rhs.y) 518 | && eq_f32(lhs.z, rhs.z) 519 | }) 520 | } 521 | (None, None) => true, 522 | _ => false, 523 | } 524 | } 525 | } 526 | impl Eq for ActionsEq<'_> {} 527 | #[derive(Debug, Clone)] 528 | struct ReplayWrapper(Replay); 529 | fn get_important<'a>(replay: &'a ReplayWrapper) -> impl Eq + Hash + 'a { 530 | let Replay { 531 | version: _, 532 | mode, 533 | beatmap_hash, 534 | player_name, 535 | replay_hash: _, 536 | count_300, 537 | count_100, 538 | count_50, 539 | count_geki, 540 | count_katsu, 541 | count_miss, 542 | score, 543 | max_combo, 544 | perfect_combo, 545 | mods, 546 | life_graph, 547 | timestamp, 548 | replay_data, 549 | raw_replay_data: _, 550 | online_score_id, 551 | } = &replay.0; 552 | fn s(s: &Option, normalize: bool) -> Option<&str> { 553 | if normalize && s.is_none() { 554 | Some("") 555 | } else { 556 | s.as_deref() 557 | } 558 | } 559 | ( 560 | mode, 561 | [ 562 | s(beatmap_hash, false), 563 | s(player_name, false), 564 | s(life_graph, true), 565 | ], 566 | [ 567 | count_300, 568 | count_100, 569 | count_50, 570 | count_geki, 571 | count_katsu, 572 | count_miss, 573 | max_combo, 574 | ], 575 | mods, 576 | score, 577 | perfect_combo, 578 | timestamp, 579 | ActionsEq(replay_data), 580 | online_score_id, 581 | ) 582 | }; 583 | impl Hash for ReplayWrapper { 584 | fn hash(&self, h: &mut H) { 585 | get_important(self).hash(h); 586 | } 587 | } 588 | impl Eq for ReplayWrapper {} 589 | impl PartialEq for ReplayWrapper { 590 | fn eq(&self, rhs: &Self) -> bool { 591 | get_important(self) == get_important(&rhs) 592 | } 593 | } 594 | for (i, list) in lists.into_iter().enumerate() { 595 | println!("processing scorelist {}", i); 596 | println!(" version: {}", list.version); 597 | println!(" beatmaps: {}", list.beatmaps.len()); 598 | println!( 599 | " total scores: {}", 600 | list.beatmaps 601 | .iter() 602 | .map(|bm| bm.scores.len()) 603 | .sum::() 604 | ); 605 | for (j, bm) in list.beatmaps.into_iter().enumerate() { 606 | if let Some(bm_hash) = bm.hash { 607 | let (meta, bm_scores) = bms_by_hash.entry(bm_hash).or_default(); 608 | *meta += merge_meta_add(i); 609 | for (_k, replay) in bm.scores.into_iter().enumerate() { 610 | println!(" {:?}", replay); 611 | let entry = bm_scores.entry(ReplayWrapper(replay)); 612 | if let std::collections::hash_map::Entry::Occupied(_) = &entry { 613 | println!(" found duplicate"); 614 | } 615 | *entry.or_default() += merge_meta_add(i); 616 | } 617 | } else { 618 | println!(" WARNING: beatmap {} has no hash, skipping", j); 619 | } 620 | } 621 | } 622 | //Rebuild scorelist 623 | for (hash, (replays_meta, replays)) in bms_by_hash { 624 | let bm = { 625 | let mut bm = BeatmapScores { 626 | hash: Some(hash), 627 | scores: replays 628 | .into_iter() 629 | .filter_map(|(wrapper, meta)| { 630 | merge_filter(merge_ctx, (meta, wrapper.0)) 631 | }) 632 | .collect(), 633 | }; 634 | for replay in bm.scores.iter_mut() { 635 | //Remove replay data and leave raw replay data only, to prevent any 636 | //unintended changes by recompression 637 | replay.replay_data = None; 638 | } 639 | bm.scores 640 | .sort_unstable_by_key(|replay| Reverse(replay.score)); 641 | bm 642 | }; 643 | if !bm.scores.is_empty() || merge_filter(merge_ctx, (replays_meta, ())).is_some() { 644 | out.beatmaps.push(bm); 645 | } 646 | } 647 | //Print info 648 | println!("output scorelist:"); 649 | println!(" beatmaps: {}", out.beatmaps.len()); 650 | println!( 651 | " total scores: {}", 652 | out.beatmaps.iter().map(|bm| bm.scores.len()).sum::() 653 | ); 654 | InMemory::Scores(out) 655 | } 656 | FileType::Replay => { 657 | if inputs.len() != 1 { 658 | bail!("replays cannot be merged") 659 | } else { 660 | inputs.into_iter().next().unwrap() 661 | } 662 | } 663 | }; 664 | Ok(out) 665 | } 666 | 667 | fn write(fmt: FileFormat, data: InMemory) -> Result> { 668 | macro_rules! file_types { 669 | ($($name:ident [$($args:tt)*]),*) => {{ 670 | match data { 671 | $( 672 | InMemory::$name(d) => match fmt { 673 | FileFormat::Bin => { 674 | let mut out = Vec::new(); 675 | d.to_writer(&mut out $($args)*)?; 676 | out 677 | } 678 | FileFormat::Ron => { 679 | ron::ser::to_string_pretty(&d, Default::default())?.into_bytes() 680 | } 681 | FileFormat::Json => { 682 | serde_json::to_vec_pretty(&d)? 683 | } 684 | }, 685 | )* 686 | } 687 | }} 688 | } 689 | Ok(file_types!(Listing[], Collections[], Scores[], Replay[, None])) 690 | } 691 | 692 | fn get_extension(ty: FileType, format: FileFormat) -> &'static str { 693 | use self::{FileFormat::*, FileType::*}; 694 | match (ty, format) { 695 | (Replay, Bin) => "osr", 696 | (_, Bin) => "db", 697 | (_, Ron) => "txt", 698 | (_, Json) => "json", 699 | } 700 | } 701 | 702 | fn unpack_path(path: Option<&Path>) -> (&str, &str) { 703 | fn flatten_osstr(opt: Option<&OsStr>) -> &str { 704 | opt.unwrap_or_default().to_str().unwrap_or_default() 705 | } 706 | path.map(|path| { 707 | ( 708 | flatten_osstr(path.file_stem()), 709 | flatten_osstr(path.extension()), 710 | ) 711 | }) 712 | .unwrap_or(("", "")) 713 | } 714 | 715 | fn guess_read_input( 716 | in_path: &Path, 717 | in_bytes: &[u8], 718 | in_ty: Option, 719 | in_fmt: Option, 720 | ) -> Result<(InMemory, FileFormat)> { 721 | let (in_name, in_ext) = unpack_path(Some(in_path)); 722 | //Guess input 723 | let mut check_order = vec![]; 724 | for file_format in [FileFormat::Bin, FileFormat::Ron, FileFormat::Json].iter() { 725 | for file_type in [ 726 | FileType::Listing, 727 | FileType::Scores, 728 | FileType::Collections, 729 | FileType::Replay, 730 | ] 731 | .iter() 732 | { 733 | check_order.push((*file_format, *file_type)); 734 | } 735 | } 736 | if let Some(in_ty) = in_ty { 737 | check_order.retain(|(_f, t)| *t == in_ty); 738 | } 739 | if let Some(in_fmt) = in_fmt { 740 | check_order.retain(|(f, _t)| *f == in_fmt); 741 | } 742 | macro_rules! priorize { 743 | (Format($fmt:ident) => $prio:expr) => {{ 744 | check_order.sort_by_key(|(f, _ty)| { 745 | let $fmt = *f; 746 | (!($prio)) as u8 747 | }); 748 | }}; 749 | (Type($ty:ident) => $prio:expr) => {{ 750 | check_order.sort_by_key(|(_fmt, t)| { 751 | let $ty = *t; 752 | (!($prio)) as u8 753 | }); 754 | }}; 755 | } 756 | //Priorize filetype 757 | match (in_name, in_ext) { 758 | ("osu!", "db") => priorize!(Type(ty) => ty == FileType::Listing), 759 | ("collection", "db") => priorize!(Type(ty) => ty == FileType::Collections), 760 | ("scores", "db") => priorize!(Type(ty) => ty == FileType::Scores), 761 | (_, "osr") => priorize!(Type(ty) => ty == FileType::Replay), 762 | _ => {} 763 | } 764 | //Prioritize fileformat 765 | match in_ext { 766 | "db" => priorize!(Format(fmt) => fmt == FileFormat::Bin), 767 | "osr" => priorize!(Format(fmt) => fmt == FileFormat::Bin), 768 | "txt" => priorize!(Format(fmt) => fmt == FileFormat::Ron || fmt == FileFormat::Json), 769 | "ron" => priorize!(Format(fmt) => fmt == FileFormat::Ron), 770 | "json" => priorize!(Format(fmt) => fmt == FileFormat::Json), 771 | _ => {} 772 | } 773 | //Actually try to parse the different options 774 | let mut errors = Vec::new(); 775 | let mut in_info = None; 776 | for &(fmt, ty) in check_order.iter() { 777 | match read(ty, fmt, in_bytes) { 778 | Ok(in_mem) => { 779 | in_info = Some((in_mem, fmt)); 780 | break; 781 | } 782 | Err(err) => { 783 | errors.push((fmt, ty, err)); 784 | } 785 | } 786 | } 787 | let (in_data, in_fmt) = in_info.ok_or_else(|| { 788 | let mut msg = format!("could not recognize input file:"); 789 | for (fmt, ty, err) in errors.iter() { 790 | write!(msg, "\n not a ({:?}/{:?}) file: {}", ty, fmt, err).unwrap(); 791 | } 792 | anyhow!(msg) 793 | })?; 794 | Ok((in_data, in_fmt)) 795 | } 796 | 797 | fn guess_output( 798 | in_path: &Path, 799 | in_ty: FileType, 800 | in_fmt: FileFormat, 801 | out_path: Option<&Path>, 802 | out_ty: Option, 803 | out_fmt: Option, 804 | ) -> (FileType, FileFormat, PathBuf) { 805 | let (out_name, out_ext) = unpack_path(out_path); 806 | //Guess type 807 | let out_ty = out_ty 808 | .or_else(|| match (out_name, out_ext) { 809 | (_, "osr") => Some(FileType::Replay), 810 | (_, _) => None, 811 | }) 812 | .unwrap_or(in_ty); 813 | //Guess format 814 | let out_fmt = out_fmt 815 | .or_else(|| { 816 | Some(match out_ext { 817 | "db" | "osr" => FileFormat::Bin, 818 | "txt" | "ron" => FileFormat::Ron, 819 | "json" => FileFormat::Json, 820 | _ => return None, 821 | }) 822 | }) 823 | .unwrap_or_else(|| match in_fmt { 824 | FileFormat::Bin => FileFormat::Ron, 825 | FileFormat::Ron | FileFormat::Json => FileFormat::Bin, 826 | }); 827 | //Guess path (if not available already) 828 | let out_path = out_path.map(|path| path.to_path_buf()).unwrap_or_else(|| { 829 | let mut out_path = in_path.to_path_buf(); 830 | let ext = get_extension(out_ty, out_fmt); 831 | out_path.set_extension(ext); 832 | let mut file_name_base = out_path.file_stem().unwrap_or_default().to_os_string(); 833 | file_name_base.push("_out"); 834 | let mut idx = 0; 835 | while out_path.exists() { 836 | let mut file_name = file_name_base.clone(); 837 | if idx > 0 { 838 | file_name.push(format!("{}", idx)); 839 | } 840 | idx += 1; 841 | out_path.set_file_name(&file_name); 842 | out_path.set_extension(ext); 843 | } 844 | out_path 845 | }); 846 | (out_ty, out_fmt, out_path) 847 | } 848 | 849 | #[derive(Debug, Clone)] 850 | struct InputOpt { 851 | path: PathBuf, 852 | ty: Option, 853 | fmt: Option, 854 | } 855 | 856 | #[derive(Debug, Copy, Clone)] 857 | enum MergeOp { 858 | Or, 859 | And, 860 | Diff, 861 | } 862 | 863 | #[derive(Debug, Clone)] 864 | struct Options { 865 | inputs: Vec, 866 | output_path: Option, 867 | output_ty: Option, 868 | output_fmt: Option, 869 | merge_op: MergeOp, 870 | } 871 | impl Options { 872 | fn parse() -> Result> { 873 | let mut inputs = Vec::new(); 874 | let mut output = (None, None, None); 875 | let mut merge_op = MergeOp::Or; 876 | 877 | let mut args = env::args().skip(1); 878 | fn parse_path(mut path: String) -> Result<(PathBuf, Option, Option)> { 879 | let mut ty = None; 880 | let mut fmt = None; 881 | while let Some(sep) = path.rfind('?') { 882 | match &path[sep + 1..] { 883 | "bin" => fmt = Some(FileFormat::Bin), 884 | "ron" | "txt" => fmt = Some(FileFormat::Ron), 885 | "json" => fmt = Some(FileFormat::Json), 886 | "listing" | "osu!" => ty = Some(FileType::Listing), 887 | "collection" | "collections" => ty = Some(FileType::Collections), 888 | "score" | "scores" => ty = Some(FileType::Scores), 889 | "osr" | "replay" => ty = Some(FileType::Replay), 890 | unknown => bail!("unknown file format/type '{}'", unknown), 891 | } 892 | path.truncate(sep); 893 | } 894 | Ok((path.into(), ty, fmt)) 895 | } 896 | let mut any_arg = false; 897 | while let Some(opt) = args.next() { 898 | any_arg = true; 899 | //Check option 900 | match &opt[..] { 901 | "-h" | "--help" => print!("{}", HELP_MSG), 902 | "-i" | "--input" => { 903 | let (path, ty, fmt) = 904 | parse_path(args.next().ok_or(anyhow!("expected input path"))?)?; 905 | inputs.push(InputOpt { path, ty, fmt }); 906 | } 907 | "-o" | "--output" => { 908 | if output.0.is_some() { 909 | bail!("too many outputs"); 910 | } 911 | let (path, ty, fmt) = 912 | parse_path(args.next().ok_or(anyhow!("expected output path"))?)?; 913 | output = (Some(path), ty, fmt); 914 | } 915 | "-u" | "--union" => { 916 | merge_op = MergeOp::Or; 917 | } 918 | "-n" | "--intersection" => { 919 | merge_op = MergeOp::And; 920 | } 921 | "-d" | "--difference" => { 922 | merge_op = MergeOp::Diff; 923 | } 924 | _ => bail!(anyhow!("unknown option '{}'", opt)), 925 | } 926 | } 927 | //Error on missing inputs 928 | ensure!(!inputs.is_empty(), "expected at least one input path"); 929 | if !inputs 930 | .iter() 931 | .all(|InputOpt { path, .. }| path.extension() == inputs[0].path.extension()) 932 | { 933 | println!("warning: inputs should have matching extensions?"); 934 | } 935 | //Print help message if no arguments were given 936 | if any_arg { 937 | let (output_path, output_ty, output_fmt) = output; 938 | Ok(Some(Options { 939 | inputs, 940 | merge_op, 941 | output_path, 942 | output_ty, 943 | output_fmt, 944 | })) 945 | } else { 946 | print!("{}", HELP_MSG); 947 | Ok(None) 948 | } 949 | } 950 | } 951 | 952 | fn run() -> Result<()> { 953 | let opt = match Options::parse()? { 954 | Some(opt) => opt, 955 | None => return Ok(()), 956 | }; 957 | 958 | //Read and parse inputs 959 | let inputs = opt 960 | .inputs 961 | .iter() 962 | .map(|InputOpt { path, ty, fmt }| -> Result<_> { 963 | println!( 964 | "reading input at \"{}\" with format {:?}/{:?}", 965 | path.display(), 966 | ty, 967 | fmt 968 | ); 969 | let bytes = fs::read(path) 970 | .with_context(|| anyhow!("failed to read input at \"{}\"", path.display()))?; 971 | let (in_mem, fmt) = guess_read_input(path, &bytes, *ty, *fmt)?; 972 | println!( 973 | " determined file format to be {:?}/{:?}", 974 | in_mem.file_type(), 975 | fmt 976 | ); 977 | Ok((in_mem, fmt)) 978 | }) 979 | .collect::>>()?; 980 | ensure!(!inputs.is_empty(), "got no inputs"); 981 | 982 | //Get output type and location 983 | let (main_in, main_in_fmt) = &inputs[0]; 984 | let (out_ty, out_fmt, out_path) = guess_output( 985 | &opt.inputs[0].path, 986 | main_in.file_type(), 987 | *main_in_fmt, 988 | opt.output_path.as_deref(), 989 | opt.output_ty, 990 | opt.output_fmt, 991 | ); 992 | 993 | //Merge inputs 994 | let merged = merge( 995 | opt.merge_op, 996 | inputs.into_iter().map(|(in_mem, _fmt)| in_mem), 997 | )?; 998 | 999 | //Serialize output 1000 | println!("serializing as a {:?}/{:?} file", out_ty, out_fmt); 1001 | ensure!( 1002 | merged.file_type() == out_ty, 1003 | "cannot convert input filetype to output filetype ({:?} -> {:?})", 1004 | merged.file_type(), 1005 | out_ty 1006 | ); 1007 | let out_bytes = write(out_fmt, merged)?; 1008 | 1009 | //Write output 1010 | println!("writing output to \"{}\"", out_path.display()); 1011 | fs::write(&out_path, &out_bytes[..]) 1012 | .with_context(|| anyhow!("failed to write output file at \"{}\"", out_path.display()))?; 1013 | 1014 | Ok(()) 1015 | } 1016 | 1017 | pub fn main() { 1018 | match run() { 1019 | Ok(()) => {} 1020 | Err(err) => println!("fatal error: {:#}", err), 1021 | } 1022 | } 1023 | -------------------------------------------------------------------------------- /src/collection.rs: -------------------------------------------------------------------------------- 1 | //! Parsing for the `collection.db` file, containing all user collections. 2 | 3 | use std::convert::identity; 4 | 5 | use crate::prelude::*; 6 | 7 | /// A structure representing the `collection.db` file. 8 | /// Contains a list of collections. 9 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 10 | #[derive(Clone, Debug, PartialEq)] 11 | pub struct CollectionList { 12 | pub version: u32, 13 | pub collections: Vec, 14 | } 15 | impl CollectionList { 16 | /// Read a collection list from its raw bytes. 17 | pub fn from_bytes(bytes: &[u8]) -> Result { 18 | Ok(collections(bytes).map(|(_rem, collections)| collections)?) 19 | } 20 | 21 | /// Read a collection list from a `collection.db` file. 22 | pub fn from_file>(path: P) -> Result { 23 | Self::from_bytes(&fs::read(path)?) 24 | } 25 | 26 | /// Writes the collection list to an arbitrary writer. 27 | pub fn to_writer(&self, mut out: W) -> io::Result<()> { 28 | self.wr(&mut out) 29 | } 30 | 31 | /// Similar to `to_writer` but writes the collection database to a file (ie. `collection.db`). 32 | pub fn to_file>(&self, path: P) -> io::Result<()> { 33 | self.to_writer(BufWriter::new(File::create(path)?)) 34 | } 35 | } 36 | 37 | /// A single collection. 38 | /// Contains a list of beatmap hashes that fall within this collection. 39 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 40 | #[derive(Clone, Debug, PartialEq)] 41 | pub struct Collection { 42 | pub name: Option, 43 | pub beatmap_hashes: Vec>, 44 | } 45 | 46 | fn collections(bytes: &[u8]) -> IResult<&[u8], CollectionList> { 47 | let (rem, version) = int(bytes)?; 48 | let (rem, collections) = length_count(map(int, identity), collection)(rem)?; 49 | 50 | let list = CollectionList { 51 | version, 52 | collections, 53 | }; 54 | 55 | Ok((rem, list)) 56 | } 57 | 58 | fn collection(bytes: &[u8]) -> IResult<&[u8], Collection> { 59 | let (rem, name) = opt_string(bytes)?; 60 | let (rem, beatmap_hashes) = length_count(map(int, identity), opt_string)(rem)?; 61 | 62 | let collection = Collection { 63 | name, 64 | beatmap_hashes, 65 | }; 66 | 67 | Ok((rem, collection)) 68 | } 69 | 70 | writer!(CollectionList [this,out] { 71 | this.version.wr(out)?; 72 | PrefixedList(&this.collections).wr(out)?; 73 | }); 74 | 75 | writer!(Collection [this,out] { 76 | this.name.wr(out)?; 77 | PrefixedList(&this.beatmap_hashes).wr(out)?; 78 | }); 79 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Representation and parsing for osu! binary formats: beatmap listing, collections, replays and 2 | //! scores. 3 | //! 4 | //! # A note on strings 5 | //! 6 | //! The osu `.db` file format allows for strings to be absent. 7 | //! This notably happens with the unicode versions of titles and authors. 8 | //! For this reason all of the parsed strings are expressed as `Option` instead of a 9 | //! simple `String`. 10 | //! You can default to an empty string by using `string.unwrap_or_default()`, which does no 11 | //! allocations and is very cheap. 12 | //! 13 | //! # A note on features and replays 14 | //! 15 | //! By default, replay data will be decompressed and parsed, using the `xz2` dependency. 16 | //! To disable this behaviour and remove the dependency on `xz2`, disable the `compression` feature: 17 | //! 18 | //! ```toml 19 | //! osu-db = { version = "*", default-features = false } 20 | //! ``` 21 | //! 22 | //! When `compression` is disabled, the 23 | //! [`Replay::replay_data`](replay/struct.Replay.html#structfield.replay_data) field will always be 24 | //! `None`, and will be ignored when writing. 25 | //! In any case, the 26 | //! [`Replay::raw_replay_data`](replay/struct.Replay.html#structfield.raw_replay_data) field is 27 | //! always available. 28 | //! 29 | //! # A note on future-proofness 30 | //! 31 | //! Osu `.db` formats are used internally by osu!, and are not intended to be shared. 32 | //! There does not seem to be any public contract on breaking changes, and breaking changes 33 | //! already occured twice (in 2014 and 2019), so this library might not work with future versions 34 | //! of osu!. 35 | //! 36 | //! It is currently guaranteed to work on osu! `.db` versions up to at least `20211103`. 37 | //! The current implementation might work for a long time, or break tomorrow. 38 | 39 | //Because otherwise compiling the large beatmap nom combinator fails 40 | #![recursion_limit = "128"] 41 | 42 | use crate::prelude::*; 43 | 44 | pub use crate::{collection::CollectionList, listing::Listing, replay::Replay, score::ScoreList}; 45 | 46 | //Writer generator macro 47 | trait Writable { 48 | type Args; 49 | fn wr_args(&self, out: &mut W, args: Self::Args) -> io::Result<()>; 50 | } 51 | trait SimpleWritable 52 | where 53 | Self: Writable, 54 | { 55 | fn wr(&self, out: &mut W) -> io::Result<()>; 56 | } 57 | impl SimpleWritable for T 58 | where 59 | T: Writable, 60 | { 61 | fn wr(&self, out: &mut W) -> io::Result<()> { 62 | self.wr_args(out, ()) 63 | } 64 | } 65 | macro_rules! writer { 66 | ($type:ty [$this:ident, $out:ident] $code:expr) => { 67 | writer!($type [$this, $out, _arg: ()] $code); 68 | }; 69 | ($type:ty [$this:ident, $out:ident, $args:ident : $args_ty:ty] $code:expr) => { 70 | impl crate::Writable for $type { 71 | type Args=$args_ty; 72 | fn wr_args(&self, $out: &mut W, $args: $args_ty) -> io::Result<()> { 73 | let $this = self; 74 | let () = $code; 75 | Ok(()) 76 | } 77 | } 78 | }; 79 | } 80 | 81 | mod prelude { 82 | pub(crate) use crate::{ 83 | boolean, byte, datetime, double, int, long, opt_string, short, single, Bit, Error, ModSet, 84 | Mode, PrefixedList, SimpleWritable, Writable, 85 | }; 86 | pub(crate) use chrono::{DateTime, Duration, TimeZone, Utc}; 87 | pub(crate) use nom::{ 88 | bytes::complete::{tag, take, take_while, take_while1}, 89 | combinator::{cond, map, map_opt, map_res, opt}, 90 | error::{Error as NomError, ErrorKind as NomErrorKind}, 91 | multi::{length_count, length_data, many0}, 92 | Err as NomErr, IResult, Needed, 93 | }; 94 | #[cfg(feature = "ser-de")] 95 | pub use serde_derive::{Deserialize, Serialize}; 96 | pub(crate) use std::{ 97 | fmt, 98 | fs::{self, File}, 99 | io::{self, BufWriter, Write}, 100 | ops, 101 | path::Path, 102 | }; 103 | #[cfg(feature = "compression")] 104 | pub use xz2::stream::Error as LzmaError; 105 | } 106 | 107 | pub mod collection; 108 | pub mod listing; 109 | pub mod replay; 110 | pub mod score; 111 | 112 | #[derive(Debug)] 113 | pub enum Error { 114 | /// Only available with the `compression` feature enabled. 115 | #[cfg(feature = "compression")] 116 | Compression(LzmaError), 117 | Io(io::Error), 118 | ParseError(NomErrorKind), 119 | ParseIncomplete(Needed), 120 | } 121 | impl fmt::Display for Error { 122 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 123 | match self { 124 | #[cfg(feature = "compression")] 125 | Error::Compression(_err) => f.write_str("failed to compress/decompress replay data"), 126 | Error::Io(_err) => f.write_str("failed to read osu .db file"), 127 | Error::ParseError(kind) => { 128 | write!(f, "failed to parse osu file: {}", kind.description()) 129 | } 130 | Error::ParseIncomplete(Needed::Size(u)) => write!( 131 | f, 132 | "failed to parse osu file: parsing requires {} bytes/chars", 133 | u 134 | ), 135 | Error::ParseIncomplete(Needed::Unknown) => { 136 | f.write_str("failed to parse osu file: parsing requires more data") 137 | } 138 | } 139 | } 140 | } 141 | impl std::error::Error for Error { 142 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 143 | match self { 144 | #[cfg(feature = "compression")] 145 | Error::Compression(err) => Some(err as &dyn std::error::Error), 146 | Error::Io(err) => Some(err as &dyn std::error::Error), 147 | Error::ParseError(_kind) => None, 148 | Error::ParseIncomplete(_needed) => None, 149 | } 150 | } 151 | } 152 | impl From for Error { 153 | fn from(err: io::Error) -> Self { 154 | Error::Io(err) 155 | } 156 | } 157 | impl From>> for Error { 158 | fn from(err: NomErr>) -> Self { 159 | match err { 160 | NomErr::Incomplete(needed) => Self::ParseIncomplete(needed), 161 | NomErr::Error(err) | NomErr::Failure(err) => Self::ParseError(err.code), 162 | } 163 | } 164 | } 165 | 166 | #[cfg(feature = "compression")] 167 | impl From for Error { 168 | fn from(err: LzmaError) -> Self { 169 | Error::Compression(err) 170 | } 171 | } 172 | 173 | trait Bit { 174 | fn bit(&self, pos: u32) -> bool; 175 | fn bit_range(&self, pos: ops::Range) -> Self; 176 | fn set_bit(&mut self, pos: u32, val: bool); 177 | fn set_bit_range(&mut self, pos: ops::Range, val: Self); 178 | } 179 | macro_rules! impl_bit { 180 | (@ $ty:ty) => { 181 | impl Bit for $ty { 182 | fn bit(&self, pos: u32) -> bool { 183 | (*self & 1 << pos) != 0 184 | } 185 | fn bit_range(&self, pos: ops::Range) -> Self { 186 | (*self & ((1<> pos.start 187 | } 188 | fn set_bit(&mut self, pos: u32, val: bool) { 189 | *self = (*self & !(1<, val: Self) { 192 | let mask = ((1<<(pos.end-pos.start))-1) << pos.start; 193 | *self = (*self & !mask) | ((val< { 198 | $( 199 | impl_bit!(@ $ty); 200 | )* 201 | } 202 | } 203 | impl_bit!(u8, u16, u32, u64); 204 | 205 | //Common fixed-size osu `.db` primitives. 206 | use nom::number::complete::le_f32 as single; 207 | use nom::number::complete::le_f64 as double; 208 | use nom::number::complete::le_u16 as short; 209 | use nom::number::complete::le_u32 as int; 210 | use nom::number::complete::le_u64 as long; 211 | use nom::number::complete::le_u8 as byte; 212 | 213 | fn boolean(bytes: &[u8]) -> IResult<&[u8], bool> { 214 | map(byte, |byte: u8| byte != 0)(bytes) 215 | } 216 | 217 | writer!(u8 [this,out] out.write_all(&this.to_le_bytes())?); 218 | writer!(u16 [this,out] out.write_all(&this.to_le_bytes())?); 219 | writer!(u32 [this,out] out.write_all(&this.to_le_bytes())?); 220 | writer!(u64 [this,out] out.write_all(&this.to_le_bytes())?); 221 | writer!(f32 [this,out] this.to_bits().wr(out)?); 222 | writer!(f64 [this,out] this.to_bits().wr(out)?); 223 | writer!(bool [this,out] (if *this {1_u8} else {0_u8}).wr(out)?); 224 | 225 | //Writer for a list of items preceded by its length as an int 226 | struct PrefixedList<'a, T>(&'a [T]); 227 | impl Writable for PrefixedList<'_, T> 228 | where 229 | T: Writable, 230 | T::Args: Clone, 231 | { 232 | type Args = T::Args; 233 | fn wr_args(&self, out: &mut W, args: T::Args) -> io::Result<()> { 234 | (self.0.len() as u32).wr(out)?; 235 | for item in self.0 { 236 | item.wr_args(out, args.clone())?; 237 | } 238 | Ok(()) 239 | } 240 | } 241 | 242 | /// Get a datetime from an amount of "windows ticks": 243 | /// The amount of 100-nanosecond units since midnight of the date 0001/01/01. 244 | fn windows_ticks_to_datetime(ticks: u64) -> DateTime { 245 | let epoch = Utc.ymd(1, 1, 1).and_hms(0, 0, 0); 246 | epoch 247 | + Duration::microseconds((ticks / 10) as i64) 248 | + Duration::nanoseconds((ticks % 10 * 100) as i64) 249 | } 250 | 251 | fn datetime(bytes: &[u8]) -> IResult<&[u8], DateTime> { 252 | map(long, windows_ticks_to_datetime)(bytes) 253 | } 254 | 255 | fn datetime_to_windows_ticks(datetime: &DateTime) -> u64 { 256 | let epoch = Utc.ymd(1, 1, 1).and_hms(0, 0, 0); 257 | let duration = datetime.signed_duration_since(epoch); 258 | let ticks_since: i64 = (duration * 10).num_microseconds().unwrap_or(0); 259 | ticks_since.max(0) as u64 260 | } 261 | writer!(DateTime [this,out] datetime_to_windows_ticks(this).wr(out)?); 262 | 263 | // The variable-length ULEB128 encoding used mainly for string lengths. 264 | fn uleb(bytes: &[u8]) -> IResult<&[u8], usize> { 265 | let (rem, prelude) = take_while(|b: u8| b.bit(7))(bytes)?; 266 | let (rem, finalizer) = byte(rem)?; 267 | 268 | let mut out = 0; 269 | let mut offset = 0; 270 | 271 | for byte in prelude { 272 | out |= (byte.bit_range(0..7) as usize) << offset; 273 | offset += 7; 274 | } 275 | 276 | out |= (finalizer as usize) << offset; 277 | 278 | Ok((rem, out)) 279 | } 280 | 281 | writer!(usize [this,out] { 282 | let mut this=*this; 283 | loop { 284 | let mut byte=this as u8; 285 | this>>=7; 286 | let continues={this!=0}; 287 | byte.set_bit(7, continues); 288 | byte.wr(out)?; 289 | if !continues {break} 290 | } 291 | }); 292 | 293 | // An optional string. 294 | fn opt_string(bytes: &[u8]) -> IResult<&[u8], Option> { 295 | let (rem, first_byte) = byte(bytes)?; 296 | 297 | match first_byte { 298 | 0x00 => Ok((rem, None)), 299 | 0x0b => { 300 | let (rem, len) = uleb(rem)?; 301 | let (rem, string) = map_res(take(len), std::str::from_utf8)(rem)?; 302 | 303 | Ok((rem, Some(string.to_owned()))) 304 | } 305 | _ => Err(NomErr::Error(NomError::new(bytes, NomErrorKind::Switch))), 306 | } 307 | } 308 | 309 | writer!(Option [this,out] { 310 | match this { 311 | Some(string) => { 312 | 0x0b_u8.wr(out)?; 313 | string.len().wr(out)?; 314 | out.write_all(string.as_bytes())?; 315 | }, 316 | None => 0x00_u8.wr(out)?, 317 | } 318 | }); 319 | 320 | /// An osu! gamemode. 321 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 322 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 323 | #[repr(u8)] 324 | pub enum Mode { 325 | Standard, 326 | Taiko, 327 | CatchTheBeat, 328 | Mania, 329 | } 330 | impl Mode { 331 | pub fn raw(self) -> u8 { 332 | self as u8 333 | } 334 | 335 | pub fn from_raw(raw: u8) -> Option { 336 | use self::Mode::*; 337 | Some(match raw { 338 | 0 => Standard, 339 | 1 => Taiko, 340 | 2 => CatchTheBeat, 341 | 3 => Mania, 342 | _ => return None, 343 | }) 344 | } 345 | } 346 | 347 | /// A single osu! mod. 348 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 349 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 350 | #[repr(u8)] 351 | pub enum Mod { 352 | NoFail, 353 | Easy, 354 | TouchDevice, 355 | Hidden, 356 | HardRock, 357 | SuddenDeath, 358 | DoubleTime, 359 | Relax, 360 | HalfTime, 361 | /// Always goes with `DoubleTime`. 362 | Nightcore, 363 | Flashlight, 364 | Autoplay, 365 | SpunOut, 366 | /// Also called "Relax2". 367 | Autopilot, 368 | Perfect, 369 | Key4, 370 | Key5, 371 | Key6, 372 | Key7, 373 | Key8, 374 | FadeIn, 375 | Random, 376 | /// Cinema. 377 | LastMod, 378 | /// Only on osu!cuttingedge it seems. 379 | TargetPractice, 380 | Key9, 381 | Coop, 382 | Key1, 383 | Key3, 384 | Key2, 385 | } 386 | impl Mod { 387 | /// Each of the 29 mods have a corresponding integer between [0,28], inclusive. 388 | /// This method retrieves its integer. 389 | pub fn raw(&self) -> u8 { 390 | *self as u8 391 | } 392 | 393 | /// Build a mod from its corresponding integer. 394 | /// Returns `None` if the integer is out-of-range (>28). 395 | pub fn from_raw(bit_offset: u8) -> Option { 396 | use self::Mod::*; 397 | Some(match bit_offset { 398 | 0 => NoFail, 399 | 1 => Easy, 400 | 2 => TouchDevice, 401 | 3 => Hidden, 402 | 4 => HardRock, 403 | 5 => SuddenDeath, 404 | 6 => DoubleTime, 405 | 7 => Relax, 406 | 8 => HalfTime, 407 | 9 => Nightcore, 408 | 10 => Flashlight, 409 | 11 => Autoplay, 410 | 12 => SpunOut, 411 | 13 => Autopilot, 412 | 14 => Perfect, 413 | 15 => Key4, 414 | 16 => Key5, 415 | 17 => Key6, 416 | 18 => Key7, 417 | 19 => Key8, 418 | 20 => FadeIn, 419 | 21 => Random, 420 | 22 => LastMod, 421 | 23 => TargetPractice, 422 | 24 => Key9, 423 | 25 => Coop, 424 | 26 => Key1, 425 | 27 => Key3, 426 | 28 => Key2, 427 | _ => return None, 428 | }) 429 | } 430 | } 431 | 432 | /// A combination of `Mod`s. 433 | /// 434 | /// Very cheap to copy around, as it is a just a wrapped 32-bit integer. 435 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 436 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 437 | pub struct ModSet(pub u32); 438 | impl ModSet { 439 | pub fn bits(&self) -> u32 { 440 | self.0 441 | } 442 | pub fn from_bits(bits: u32) -> ModSet { 443 | ModSet(bits) 444 | } 445 | 446 | /// Create a `ModSet` with no mods included. 447 | pub fn empty() -> ModSet { 448 | ModSet::from_bits(0) 449 | } 450 | 451 | /// Check whether the set contains the given mod. 452 | pub fn contains(&self, m: Mod) -> bool { 453 | self.bits().bit(m.raw() as u32) 454 | } 455 | 456 | /// Make a new set of mods with the given mod included or not included. 457 | pub fn set(&self, m: Mod, include: bool) -> ModSet { 458 | let mut bits = self.bits(); 459 | bits.set_bit(m.raw() as u32, include); 460 | ModSet::from_bits(bits) 461 | } 462 | 463 | /// Make a new set of mods with the given mod included. 464 | pub fn with(&self, m: Mod) -> ModSet { 465 | self.set(m, true) 466 | } 467 | 468 | /// Make a new set of mods with the given mod removed. 469 | pub fn without(&self, m: Mod) -> ModSet { 470 | self.set(m, false) 471 | } 472 | } 473 | 474 | #[cfg(test)] 475 | mod test { 476 | use super::*; 477 | 478 | #[test] 479 | fn basic() { 480 | assert_eq!( 481 | byte::<_, NomError<&[u8]>>(" ".as_bytes()), 482 | Ok((&[][..], 32)) 483 | ); 484 | assert_eq!( 485 | short::<_, NomError<&[u8]>>(&[10, 2][..]), 486 | Ok((&[][..], 522)) 487 | ); 488 | assert_eq!( 489 | int::<_, NomError<&[u8]>>(&[10, 10, 0, 0, 3][..]), 490 | Ok((&[3][..], 2570)) 491 | ); 492 | assert_eq!( 493 | long::<_, NomError<&[u8]>>(&[0, 0, 1, 0, 2, 0, 3, 0][..]), 494 | Ok((&[][..], 844_433_520_132_096)) 495 | ); 496 | assert_eq!( 497 | single::<_, NomError<&[u8]>>(&[0, 0, 0b00100000, 0b00111110, 4][..]), 498 | Ok((&[4][..], 0.15625)) 499 | ); 500 | assert_eq!( 501 | double::<_, NomError<&[u8]>>(&[0b00000010, 0, 0, 0, 0, 0, 0b11110000, 0b00111111][..]), 502 | Ok((&[][..], 1.0000000000000004)) 503 | ); 504 | assert_eq!(boolean(&[34, 4, 0][..]), Ok((&[4, 0][..], true))); 505 | assert_eq!( 506 | int::<_, NomError<&[u8]>>(&[3, 5, 4][..]), 507 | Err(NomErr::Error(NomError::new( 508 | &[3, 5, 4][..], 509 | NomErrorKind::Eof 510 | ))) 511 | ); 512 | assert_eq!( 513 | boolean(&[][..]), 514 | Err(NomErr::Error(NomError::new(&[][..], NomErrorKind::Eof))) 515 | ); 516 | assert_eq!( 517 | double::<_, NomError<&[u8]>>(&[14, 25, 15, 24, 3][..]), 518 | Err(NomErr::Error(NomError::new( 519 | &[14, 25, 15, 24, 3][..], 520 | NomErrorKind::Eof 521 | ))) 522 | ); 523 | } 524 | 525 | #[test] 526 | fn uleb128() { 527 | assert_eq!(uleb(&[70]), Ok((&[][..], 70))); 528 | assert_eq!( 529 | uleb(&[]), 530 | Err(NomErr::Error(NomError::new(&[][..], NomErrorKind::Eof))) 531 | ); 532 | assert_eq!(uleb(&[129, 2]), Ok((&[][..], 257))); 533 | assert_eq!(uleb(&[124, 2]), Ok((&[2][..], 124))); 534 | } 535 | 536 | #[test] 537 | fn strings() { 538 | let long_str = "w".repeat(129); 539 | 540 | assert_eq!(opt_string(b"\x00sf"), Ok((&b"sf"[..], None))); 541 | assert_eq!( 542 | opt_string(b"\x0b\x02ghf"), 543 | Ok((&b"f"[..], Some("gh".to_string()))) 544 | ); 545 | //Invalid string header 546 | assert!(opt_string(b"\x01ww").is_err()); 547 | //Invalid utf-8 548 | assert!(opt_string(b"\x0b\x01\xff").is_err()); 549 | //Missing string length 550 | assert_eq!( 551 | opt_string(b"\x0b"), 552 | Err(NomErr::Error(NomError::new(&[][..], NomErrorKind::Eof))) 553 | ); 554 | //Long strings 555 | let mut raw = Vec::from(&b"\x0b\x81\x01"[..]); 556 | raw.extend_from_slice(long_str.as_bytes()); 557 | raw.extend_from_slice(&b"afaf"[..]); 558 | assert_eq!(opt_string(&raw), Ok((&b"afaf"[..], Some(long_str)))); 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /src/listing.rs: -------------------------------------------------------------------------------- 1 | //! Parsing for the `osu!.db` file, containing cached information about the beatmap listing. 2 | 3 | use crate::prelude::*; 4 | use std::{convert::identity, hash::Hash}; 5 | 6 | /// In these `osu!.db` versions several breaking changes were introduced. 7 | /// While parsing, these changes are automatically handled depending on the `osu!.db` version. 8 | const CHANGE_20140609: u32 = 20140609; 9 | const CHANGE_20191106: u32 = 20191106; 10 | const CHANGE_20250107: u32 = 20250107; 11 | 12 | /// A structure representing the `osu!.db` binary database. 13 | /// This database contains pre-processed data and settings for all available osu! beatmaps. 14 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 15 | #[derive(Debug, Clone, PartialEq)] 16 | pub struct Listing { 17 | /// The `osu!.db` version number. 18 | /// This is a decimal number in the form `YYYYMMDD` (eg. `20150203`). 19 | pub version: u32, 20 | 21 | /// The amount of folders within the "Songs" directory. 22 | /// Probably for quick checking of changes within the directory. 23 | pub folder_count: u32, 24 | 25 | /// Whether the account is locked/banned, and when will be it be unbanned. 26 | pub unban_date: Option>, 27 | 28 | /// Self-explanatory. 29 | pub player_name: Option, 30 | 31 | /// All stored beatmaps and the information stored about them. 32 | /// The main bulk of information. 33 | pub beatmaps: Vec, 34 | 35 | /// User permissions (0 = None, 1 = Normal, 2 = Moderator, 4 = Supporter, 36 | /// 8 = Friend, 16 = peppy, 32 = World Cup staff) 37 | pub user_permissions: u32, 38 | } 39 | impl Listing { 40 | pub fn from_bytes(bytes: &[u8]) -> Result { 41 | Ok(listing(bytes).map(|(_rem, listing)| listing)?) 42 | } 43 | 44 | /// Parse a listing from the `osu!.db` database file. 45 | pub fn from_file>(path: P) -> Result { 46 | Self::from_bytes(&fs::read(path)?) 47 | } 48 | 49 | /// Write the listing to an arbitrary writer. 50 | pub fn to_writer(&self, mut out: W) -> io::Result<()> { 51 | self.wr(&mut out) 52 | } 53 | 54 | /// Similar to `to_writer` but writes the listing to a file (ie. `osu!.db`). 55 | pub fn save>(&self, path: P) -> io::Result<()> { 56 | self.to_writer(BufWriter::new(File::create(path)?)) 57 | } 58 | } 59 | 60 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 61 | #[derive(Debug, Clone, PartialEq)] 62 | pub struct Beatmap { 63 | /// The name of the artist without special characters. 64 | pub artist_ascii: Option, 65 | /// The unrestrained artist name. 66 | pub artist_unicode: Option, 67 | /// The song title without special characters. 68 | pub title_ascii: Option, 69 | /// The unrestrained song title. 70 | pub title_unicode: Option, 71 | /// The name of the beatmap mapper. 72 | pub creator: Option, 73 | /// The name of this specific difficulty. 74 | pub difficulty_name: Option, 75 | /// The filename of the song file. 76 | pub audio: Option, 77 | /// The MD5 hash of the beatmap. 78 | pub hash: Option, 79 | /// The filename of the `.osu` file corresponding to this specific difficulty. 80 | pub file_name: Option, 81 | pub status: RankedStatus, 82 | pub hitcircle_count: u16, 83 | pub slider_count: u16, 84 | pub spinner_count: u16, 85 | pub last_modified: DateTime, 86 | pub approach_rate: f32, 87 | pub circle_size: f32, 88 | pub hp_drain: f32, 89 | pub overall_difficulty: f32, 90 | pub slider_velocity: f64, 91 | pub std_ratings: StarRatings, 92 | pub taiko_ratings: StarRatings, 93 | pub ctb_ratings: StarRatings, 94 | pub mania_ratings: StarRatings, 95 | /// Drain time in seconds. 96 | pub drain_time: u32, 97 | /// Total beatmap time in milliseconds. 98 | pub total_time: u32, 99 | /// When should the song start playing when previewed, in milliseconds since the start of the 100 | /// song. 101 | pub preview_time: u32, 102 | pub timing_points: Vec, 103 | pub beatmap_id: i32, 104 | pub beatmapset_id: i32, 105 | pub thread_id: u32, 106 | pub std_grade: Grade, 107 | pub taiko_grade: Grade, 108 | pub ctb_grade: Grade, 109 | pub mania_grade: Grade, 110 | pub local_beatmap_offset: u16, 111 | pub stack_leniency: f32, 112 | pub mode: Mode, 113 | /// Where did the song come from, if anywhere. 114 | pub song_source: Option, 115 | /// Song tags, separated by whitespace. 116 | pub tags: Option, 117 | pub online_offset: u16, 118 | pub title_font: Option, 119 | /// Whether the beatmap has been played, and if it has, when was it last played. 120 | pub last_played: Option>, 121 | /// Whether the beatmap was in `osz2` format. 122 | pub is_osz2: bool, 123 | /// The folder name of the beatmapset within the "Songs" folder. 124 | pub folder_name: Option, 125 | /// When was the beatmap last checked against the online osu! repository. 126 | pub last_online_check: DateTime, 127 | pub ignore_sounds: bool, 128 | pub ignore_skin: bool, 129 | pub disable_storyboard: bool, 130 | pub disable_video: bool, 131 | pub visual_override: bool, 132 | /// Quoting the wiki: "Unknown. Only present if version is less than 20140609". 133 | pub mysterious_short: Option, 134 | /// Who knows. 135 | /// 136 | /// Perhaps an early attempt at "last modified", but scrapped once peppy noticed it only had 137 | /// 32 bits. 138 | pub mysterious_last_modified: u32, 139 | pub mania_scroll_speed: u8, 140 | } 141 | 142 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 143 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 144 | pub enum RankedStatus { 145 | Unknown, 146 | Unsubmitted, 147 | /// Any of the three. 148 | PendingWipGraveyard, 149 | Ranked, 150 | Approved, 151 | Qualified, 152 | Loved, 153 | } 154 | impl RankedStatus { 155 | pub fn from_raw(byte: u8) -> Option { 156 | use self::RankedStatus::*; 157 | Some(match byte { 158 | 0 => Unknown, 159 | 1 => Unsubmitted, 160 | 2 => PendingWipGraveyard, 161 | 4 => Ranked, 162 | 5 => Approved, 163 | 6 => Qualified, 164 | 7 => Loved, 165 | _ => return None, 166 | }) 167 | } 168 | 169 | pub fn raw(self) -> u8 { 170 | use self::RankedStatus::*; 171 | match self { 172 | Unknown => 0, 173 | Unsubmitted => 1, 174 | PendingWipGraveyard => 2, 175 | Ranked => 4, 176 | Approved => 5, 177 | Qualified => 6, 178 | Loved => 7, 179 | } 180 | } 181 | } 182 | 183 | /// A list of the precalculated amount of difficulty stars a given mod combination yields for a 184 | /// beatmap. 185 | /// 186 | /// You might want to convert this list into a map using 187 | /// `ratings.into_iter().collect::>()` or variations, allowing for quick indexing with 188 | /// different mod combinations. 189 | /// 190 | /// Note that old "osu!.db" files (before the 2014/06/09 version) do not have these ratings. 191 | pub type StarRatings = Vec<(ModSet, f64)>; 192 | 193 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 194 | #[derive(Debug, Clone, PartialEq)] 195 | pub struct TimingPoint { 196 | /// The bpm of the timing point. 197 | pub bpm: f64, 198 | /// The amount of milliseconds from the start of the song this timing point is located on. 199 | pub offset: f64, 200 | /// Whether the timing point inherits or not. 201 | /// 202 | /// Basically, inherited timing points are absolute, and define a new bpm independent of any previous bpms. 203 | /// On the other hand, timing points that do not inherit have a negative bpm representing a percentage of the 204 | /// bpm of the previous timing point. 205 | /// See the osu wiki on the `.osu` format for more details. 206 | pub inherits: bool, 207 | } 208 | 209 | /// A grade obtained by passing a beatmap. 210 | /// Also called a rank. 211 | /// 212 | /// Note that currently grades are just exposed as a raw byte. 213 | /// I am not sure of how do this bytes map to grades as of now. 214 | /// TODO: Figure out grades. 215 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 216 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] 217 | pub enum Grade { 218 | /// SS+, silver SS rank 219 | /// Ie. only perfect scores with hidden mod enabled. 220 | SSPlus, 221 | /// S+, silver S rank 222 | /// Ie. highest performance with hidden mod enabled. 223 | SPlus, 224 | /// SS rank 225 | /// Ie. only perfect scores. 226 | SS, 227 | S, 228 | A, 229 | B, 230 | C, 231 | D, 232 | /// No rank achieved yet. 233 | Unplayed, 234 | } 235 | impl Grade { 236 | pub fn raw(self) -> u8 { 237 | use self::Grade::*; 238 | match self { 239 | SSPlus => 0, 240 | SPlus => 1, 241 | SS => 2, 242 | S => 3, 243 | A => 4, 244 | B => 5, 245 | C => 6, 246 | D => 7, 247 | Unplayed => 9, 248 | } 249 | } 250 | pub fn from_raw(raw: u8) -> Option { 251 | use self::Grade::*; 252 | Some(match raw { 253 | 0 => SSPlus, 254 | 1 => SPlus, 255 | 2 => SS, 256 | 3 => S, 257 | 4 => A, 258 | 5 => B, 259 | 6 => C, 260 | 7 => D, 261 | 9 => Unplayed, 262 | _ => return None, 263 | }) 264 | } 265 | } 266 | 267 | fn listing(bytes: &[u8]) -> IResult<&[u8], Listing> { 268 | let (rem, version) = int(bytes)?; 269 | let (rem, folder_count) = int(rem)?; 270 | let (rem, account_unlocked) = boolean(rem)?; 271 | let (rem, unlock_date) = datetime(rem)?; 272 | let (rem, player_name) = opt_string(rem)?; 273 | let (rem, beatmaps) = length_count(map(int, identity), |bytes| beatmap(bytes, version))(rem)?; 274 | let (rem, user_permissions) = int(rem)?; 275 | 276 | let listing = Listing { 277 | version, 278 | folder_count, 279 | unban_date: build_option(account_unlocked, unlock_date), 280 | player_name, 281 | beatmaps, 282 | user_permissions, 283 | }; 284 | 285 | Ok((rem, listing)) 286 | } 287 | 288 | writer!(Listing [this, out] { 289 | this.version.wr(out)?; 290 | this.folder_count.wr(out)?; 291 | write_option(out,this.unban_date,0_u64)?; 292 | this.player_name.wr(out)?; 293 | PrefixedList(&this.beatmaps).wr_args(out,this.version)?; 294 | this.user_permissions.wr(out)?; 295 | }); 296 | 297 | fn beatmap(bytes: &[u8], version: u32) -> IResult<&[u8], Beatmap> { 298 | let (rem, _beatmap_size) = cond(version < CHANGE_20191106, int)(bytes)?; 299 | let (rem, artist_ascii) = opt_string(rem)?; 300 | let (rem, artist_unicode) = opt_string(rem)?; 301 | let (rem, title_ascii) = opt_string(rem)?; 302 | let (rem, title_unicode) = opt_string(rem)?; 303 | let (rem, creator) = opt_string(rem)?; 304 | let (rem, difficulty_name) = opt_string(rem)?; 305 | let (rem, audio) = opt_string(rem)?; 306 | let (rem, hash) = opt_string(rem)?; 307 | let (rem, file_name) = opt_string(rem)?; 308 | let (rem, status) = ranked_status(rem)?; 309 | let (rem, hitcircle_count) = short(rem)?; 310 | let (rem, slider_count) = short(rem)?; 311 | let (rem, spinner_count) = short(rem)?; 312 | let (rem, last_modified) = datetime(rem)?; 313 | let (rem, approach_rate) = difficulty_value(rem, version)?; 314 | let (rem, circle_size) = difficulty_value(rem, version)?; 315 | let (rem, hp_drain) = difficulty_value(rem, version)?; 316 | let (rem, overall_difficulty) = difficulty_value(rem, version)?; 317 | let (rem, slider_velocity) = double(rem)?; 318 | let (rem, std_ratings) = star_ratings(rem, version)?; 319 | let (rem, taiko_ratings) = star_ratings(rem, version)?; 320 | let (rem, ctb_ratings) = star_ratings(rem, version)?; 321 | let (rem, mania_ratings) = star_ratings(rem, version)?; 322 | let (rem, drain_time) = int(rem)?; 323 | let (rem, total_time) = int(rem)?; 324 | let (rem, preview_time) = int(rem)?; 325 | let (rem, timing_points) = length_count(map(int, identity), timing_point)(rem)?; 326 | let (rem, beatmap_id) = int(rem)?; 327 | let (rem, beatmapset_id) = int(rem)?; 328 | let (rem, thread_id) = int(rem)?; 329 | let (rem, std_grade) = grade(rem)?; 330 | let (rem, taiko_grade) = grade(rem)?; 331 | let (rem, ctb_grade) = grade(rem)?; 332 | let (rem, mania_grade) = grade(rem)?; 333 | let (rem, local_beatmap_offset) = short(rem)?; 334 | let (rem, stack_leniency) = single(rem)?; 335 | let (rem, mode) = map_opt(byte, Mode::from_raw)(rem)?; 336 | let (rem, song_source) = opt_string(rem)?; 337 | let (rem, tags) = opt_string(rem)?; 338 | let (rem, online_offset) = short(rem)?; 339 | let (rem, title_font) = opt_string(rem)?; 340 | let (rem, unplayed) = boolean(rem)?; 341 | let (rem, last_played) = datetime(rem)?; 342 | let (rem, is_osz2) = boolean(rem)?; 343 | let (rem, folder_name) = opt_string(rem)?; 344 | let (rem, last_online_check) = datetime(rem)?; 345 | let (rem, ignore_sounds) = boolean(rem)?; 346 | let (rem, ignore_skin) = boolean(rem)?; 347 | let (rem, disable_storyboard) = boolean(rem)?; 348 | let (rem, disable_video) = boolean(rem)?; 349 | let (rem, visual_override) = boolean(rem)?; 350 | let (rem, mysterious_short) = cond(version < CHANGE_20140609, short)(rem)?; 351 | let (rem, mysterious_last_modified) = int(rem)?; 352 | let (rem, mania_scroll_speed) = byte(rem)?; 353 | 354 | let map = Beatmap { 355 | artist_ascii, 356 | artist_unicode, 357 | title_ascii, 358 | title_unicode, 359 | creator, 360 | difficulty_name, 361 | audio, 362 | hash, 363 | file_name, 364 | status, 365 | hitcircle_count, 366 | slider_count, 367 | spinner_count, 368 | last_modified, 369 | approach_rate, 370 | circle_size, 371 | hp_drain, 372 | overall_difficulty, 373 | slider_velocity, 374 | std_ratings, 375 | taiko_ratings, 376 | ctb_ratings, 377 | mania_ratings, 378 | drain_time, 379 | total_time, 380 | preview_time, 381 | timing_points, 382 | beatmap_id: beatmap_id as i32, 383 | beatmapset_id: beatmapset_id as i32, 384 | thread_id, 385 | std_grade, 386 | taiko_grade, 387 | ctb_grade, 388 | mania_grade, 389 | local_beatmap_offset, 390 | stack_leniency, 391 | mode, 392 | song_source, 393 | tags, 394 | online_offset, 395 | title_font, 396 | last_played: build_option(unplayed, last_played), 397 | is_osz2, 398 | folder_name, 399 | last_online_check, 400 | ignore_sounds, 401 | ignore_skin, 402 | disable_storyboard, 403 | disable_video, 404 | visual_override, 405 | mysterious_short, 406 | mysterious_last_modified, 407 | mania_scroll_speed, 408 | }; 409 | 410 | Ok((rem, map)) 411 | } 412 | 413 | writer!(Beatmap [this,out,version: u32] { 414 | //Write into a writer without prefixing the length 415 | fn write_dry(this: &Beatmap, out: &mut W, version: u32) -> io::Result<()> { 416 | macro_rules! wr_difficulty_value { 417 | ($f32:expr) => {{ 418 | if version>=CHANGE_20140609 { 419 | $f32.wr(out)?; 420 | }else{ 421 | ($f32 as u8).wr(out)?; 422 | } 423 | }}; 424 | } 425 | this.artist_ascii.wr(out)?; 426 | this.artist_unicode.wr(out)?; 427 | this.title_ascii.wr(out)?; 428 | this.title_unicode.wr(out)?; 429 | this.creator.wr(out)?; 430 | this.difficulty_name.wr(out)?; 431 | this.audio.wr(out)?; 432 | this.hash.wr(out)?; 433 | this.file_name.wr(out)?; 434 | this.status.wr(out)?; 435 | this.hitcircle_count.wr(out)?; 436 | this.slider_count.wr(out)?; 437 | this.spinner_count.wr(out)?; 438 | this.last_modified.wr(out)?; 439 | wr_difficulty_value!(this.approach_rate); 440 | wr_difficulty_value!(this.circle_size); 441 | wr_difficulty_value!(this.hp_drain); 442 | wr_difficulty_value!(this.overall_difficulty); 443 | this.slider_velocity.wr(out)?; 444 | this.std_ratings.wr_args(out,version)?; 445 | this.taiko_ratings.wr_args(out,version)?; 446 | this.ctb_ratings.wr_args(out,version)?; 447 | this.mania_ratings.wr_args(out,version)?; 448 | this.drain_time.wr(out)?; 449 | this.total_time.wr(out)?; 450 | this.preview_time.wr(out)?; 451 | PrefixedList(&this.timing_points).wr(out)?; 452 | (this.beatmap_id as u32).wr(out)?; 453 | (this.beatmapset_id as u32).wr(out)?; 454 | this.thread_id.wr(out)?; 455 | this.std_grade.wr(out)?; 456 | this.taiko_grade.wr(out)?; 457 | this.ctb_grade.wr(out)?; 458 | this.mania_grade.wr(out)?; 459 | this.local_beatmap_offset.wr(out)?; 460 | this.stack_leniency.wr(out)?; 461 | this.mode.raw().wr(out)?; 462 | this.song_source.wr(out)?; 463 | this.tags.wr(out)?; 464 | this.online_offset.wr(out)?; 465 | this.title_font.wr(out)?; 466 | write_option(out,this.last_played,0_u64)?; 467 | this.is_osz2.wr(out)?; 468 | this.folder_name.wr(out)?; 469 | this.last_online_check.wr(out)?; 470 | this.ignore_sounds.wr(out)?; 471 | this.ignore_skin.wr(out)?; 472 | this.disable_storyboard.wr(out)?; 473 | this.disable_video.wr(out)?; 474 | this.visual_override.wr(out)?; 475 | if version IResult<&[u8], TimingPoint> { 497 | let (rem, bpm) = double(bytes)?; 498 | let (rem, offset) = double(rem)?; 499 | let (rem, inherits) = boolean(rem)?; 500 | 501 | let timing_point = TimingPoint { 502 | bpm, 503 | offset, 504 | inherits, 505 | }; 506 | 507 | Ok((rem, timing_point)) 508 | } 509 | 510 | writer!(TimingPoint [this,out] { 511 | this.bpm.wr(out)?; 512 | this.offset.wr(out)?; 513 | this.inherits.wr(out)?; 514 | }); 515 | 516 | fn star_ratings(bytes: &[u8], version: u32) -> IResult<&[u8], Vec<(ModSet, f64)>> { 517 | if version >= CHANGE_20140609 { 518 | length_count(map(int, identity), |bytes| star_rating(bytes, version))(bytes) 519 | } else { 520 | Ok((bytes, Vec::new())) 521 | } 522 | } 523 | 524 | // Before breaking change 20250107 this was an Int-Double pair, which changed 525 | // to an Int-Float pair to massively reduce storage overhead. 526 | fn star_rating(bytes: &[u8], version: u32) -> IResult<&[u8], (ModSet, f64)> { 527 | let (rem, _tag) = tag(&[0x08])(bytes)?; 528 | let (rem, mods) = map(int, ModSet::from_bits)(rem)?; 529 | 530 | if version < CHANGE_20250107 { 531 | let (rem, _tag) = tag(&[0x0d])(rem)?; 532 | let (rem, stars) = double(rem)?; 533 | Ok((rem, (mods, stars))) 534 | } else { 535 | let (rem, _tag) = tag(&[0x0c])(rem)?; 536 | let (rem, stars) = single(rem)?; 537 | Ok((rem, (mods, stars as f64))) 538 | } 539 | } 540 | 541 | writer!(Vec<(ModSet,f64)> [this,out,version: u32] { 542 | if version>=CHANGE_20140609 { 543 | PrefixedList(this).wr_args(out, version)?; 544 | } 545 | }); 546 | writer!((ModSet,f64) [this,out,version: u32] { 547 | 0x08_u8.wr(out)?; 548 | this.0.bits().wr(out)?; 549 | 550 | if version < CHANGE_20250107 { 551 | 0x0d_u8.wr(out)?; 552 | this.1.wr(out)?; 553 | } else { 554 | 0x0c_u8.wr(out)?; 555 | (this.1 as f32).wr(out)?; 556 | } 557 | }); 558 | 559 | /// Before the breaking change in 2014 several difficulty values were stored as bytes. 560 | /// After it they were stored as single floats. 561 | /// Accomodate this differences. 562 | fn difficulty_value(bytes: &[u8], version: u32) -> IResult<&[u8], f32> { 563 | if version >= CHANGE_20140609 { 564 | single(bytes) 565 | } else { 566 | byte(bytes).map(|(rem, b)| (rem, b as f32)) 567 | } 568 | } 569 | 570 | fn ranked_status(bytes: &[u8]) -> IResult<&[u8], RankedStatus> { 571 | map_opt(byte, RankedStatus::from_raw)(bytes) 572 | } 573 | 574 | writer!(RankedStatus [this,out] this.raw().wr(out)?); 575 | 576 | fn grade(bytes: &[u8]) -> IResult<&[u8], Grade> { 577 | map_opt(byte, Grade::from_raw)(bytes) 578 | } 579 | 580 | writer!(Grade [this,out] this.raw().wr(out)?); 581 | 582 | fn build_option(is_none: bool, content: T) -> Option { 583 | if is_none { 584 | None 585 | } else { 586 | Some(content) 587 | } 588 | } 589 | fn write_option( 590 | out: &mut W, 591 | opt: Option, 592 | def: D, 593 | ) -> io::Result<()> { 594 | match opt { 595 | Some(t) => { 596 | false.wr(out)?; 597 | t.wr(out)?; 598 | } 599 | None => { 600 | true.wr(out)?; 601 | def.wr(out)?; 602 | } 603 | } 604 | Ok(()) 605 | } 606 | -------------------------------------------------------------------------------- /src/replay.rs: -------------------------------------------------------------------------------- 1 | //! Parsing for replay and score files, which are very similar. 2 | 3 | use crate::prelude::*; 4 | 5 | /// The LZMA compression level (a number between 0 and 9) used to write replay data when it is 6 | /// not otherwise specified. 7 | const DEFAULT_COMPRESSION_LEVEL: u32 = 5; 8 | 9 | /// An osu! replay. 10 | /// The replay might come from a large `ScoreList` score database, or from an individual standalone 11 | /// `.osr` file. 12 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 13 | #[derive(Debug, Clone, PartialEq)] 14 | pub struct Replay { 15 | /// The gamemode the replay was scored in. 16 | pub mode: Mode, 17 | /// The `.db` version of the replay file. 18 | /// If the replay is inside a `scores.db` file, the version should be redundant with it (?). 19 | pub version: u32, 20 | /// The MD5 hash of the beatmap played. 21 | pub beatmap_hash: Option, 22 | /// The name of the player who scored the replay. 23 | pub player_name: Option, 24 | /// The replay-specific MD5 hash. 25 | pub replay_hash: Option, 26 | /// Amount of 300s (fruits in ctb). 27 | pub count_300: u16, 28 | /// Amount of 100s (drops in ctb, 150s in taiko and 200s in mania). 29 | pub count_100: u16, 30 | /// Amount of 50s (droplets in ctb). 31 | pub count_50: u16, 32 | /// Amount of gekis ("MAX scores" or "rainbow 300s" in mania). 33 | pub count_geki: u16, 34 | /// Amount of katsus (200s in mania, droplet misses in ctb). 35 | pub count_katsu: u16, 36 | /// Amount of misses (fruit + drop misses in ctb). 37 | pub count_miss: u16, 38 | /// The numerical score achieved. 39 | pub score: u32, 40 | pub max_combo: u16, 41 | pub perfect_combo: bool, 42 | /// The mod combination with which the replay was done. 43 | pub mods: ModSet, 44 | /// A string representing a graph of how much life bar did the player have along the beatmap. 45 | /// 46 | /// It is a comma-separated list of human-readable entries in the form `|`, where 47 | /// `` is the amount of milliseconds since the start of the song and `` is a 48 | /// number between 0 and 1 representing the amount of life left. 49 | pub life_graph: Option, 50 | /// When was the replay scored. 51 | pub timestamp: DateTime, 52 | /// Decompressed replay data. 53 | /// 54 | /// Only available on standalone `.osr` replays, and if the `compression` feature is enabled 55 | /// (enabled by default). 56 | /// 57 | /// When writing `.osr` files (and `.osr` files only), if the `compression` feature is enabled 58 | /// and this field is `Some`, these actions will be compressed and written. Otherwise, 59 | /// `raw_replay_data` will be written instead. 60 | pub replay_data: Option>, 61 | /// Raw replay data, available on `.osr` files even if the `compression` feature is not enabled. 62 | /// 63 | /// When writing, this field is used as a fallback if `replay_data` is `None` or the 64 | /// `compression` feature is disabled. 65 | pub raw_replay_data: Option>, 66 | /// Online score id. 67 | /// Only has a useful value on replays embedded in a `ScoreList`. 68 | pub online_score_id: u64, 69 | } 70 | impl Replay { 71 | /// Parse a replay from its raw bytes. 72 | pub fn from_bytes(bytes: &[u8]) -> Result { 73 | replay(bytes, true).map(|(_rem, replay)| replay) 74 | } 75 | 76 | /// Read a replay from a standalone `.osr` osu! replay file. 77 | pub fn from_file>(path: P) -> Result { 78 | Self::from_bytes(&fs::read(path)?) 79 | } 80 | 81 | /// Write the replay to an arbitrary writer, with the given compression level. 82 | /// 83 | /// If the compression level is `None` the arbitrary default 84 | /// `replay::DEFAULT_COMPRESSION_LEVEL` will be used. 85 | /// If the `compression` feature is disabled this argument has no effect. 86 | pub fn to_writer( 87 | &self, 88 | mut out: W, 89 | compression_level: Option, 90 | ) -> io::Result<()> { 91 | self.wr_args( 92 | &mut out, 93 | Some(compression_level.unwrap_or(DEFAULT_COMPRESSION_LEVEL)), 94 | ) 95 | } 96 | 97 | /// Similar to `to_writer` but writes the replay to an `osr` file. 98 | pub fn save>(&self, path: P, compression_level: Option) -> io::Result<()> { 99 | self.to_writer(BufWriter::new(File::create(path)?), compression_level) 100 | } 101 | } 102 | 103 | pub(crate) fn replay(bytes: &[u8], standalone: bool) -> Result<(&[u8], Replay), Error> { 104 | let (rem, mode) = map_opt(byte, Mode::from_raw)(bytes)?; 105 | let (rem, version) = int(rem)?; 106 | let (rem, beatmap_hash) = opt_string(rem)?; 107 | let (rem, player_name) = opt_string(rem)?; 108 | let (rem, replay_hash) = opt_string(rem)?; 109 | let (rem, count_300) = short(rem)?; 110 | let (rem, count_100) = short(rem)?; 111 | let (rem, count_50) = short(rem)?; 112 | let (rem, count_geki) = short(rem)?; 113 | let (rem, count_katsu) = short(rem)?; 114 | let (rem, count_miss) = short(rem)?; 115 | let (rem, score) = int(rem)?; 116 | let (rem, max_combo) = short(rem)?; 117 | let (rem, perfect_combo) = boolean(rem)?; 118 | let (rem, mods) = map(int, ModSet::from_bits)(rem)?; 119 | let (rem, life_graph) = opt_string(rem)?; 120 | let (rem, timestamp) = datetime(rem)?; 121 | 122 | let (rem, raw_replay_data) = if standalone { 123 | map(length_data(int), Some)(rem)? 124 | } else { 125 | let (rem, _tag) = tag(&[0xff, 0xff, 0xff, 0xff])(rem)?; 126 | 127 | (rem, None) 128 | }; 129 | 130 | let replay_data = parse_replay_data(raw_replay_data)?; 131 | let (rem, online_score_id) = long(rem)?; 132 | 133 | let replay = Replay { 134 | mode, 135 | version, 136 | beatmap_hash, 137 | player_name, 138 | replay_hash, 139 | count_300, 140 | count_100, 141 | count_50, 142 | count_geki, 143 | count_katsu, 144 | count_miss, 145 | score, 146 | max_combo, 147 | perfect_combo, 148 | mods, 149 | life_graph, 150 | timestamp, 151 | replay_data, 152 | raw_replay_data: raw_replay_data.map(ToOwned::to_owned), 153 | online_score_id, 154 | }; 155 | 156 | Ok((rem, replay)) 157 | } 158 | writer!(Replay [this,out,compress_data: Option] { 159 | this.mode.raw().wr(out)?; 160 | this.version.wr(out)?; 161 | this.beatmap_hash.wr(out)?; 162 | this.player_name.wr(out)?; 163 | this.replay_hash.wr(out)?; 164 | this.count_300.wr(out)?; 165 | this.count_100.wr(out)?; 166 | this.count_50.wr(out)?; 167 | this.count_geki.wr(out)?; 168 | this.count_katsu.wr(out)?; 169 | this.count_miss.wr(out)?; 170 | this.score.wr(out)?; 171 | this.max_combo.wr(out)?; 172 | this.perfect_combo.wr(out)?; 173 | this.mods.bits().wr(out)?; 174 | this.life_graph.wr(out)?; 175 | this.timestamp.wr(out)?; 176 | if let Some(compression_level) = compress_data { 177 | write_replay_data( 178 | this.replay_data.as_deref(), 179 | this.raw_replay_data.as_deref(), 180 | out, 181 | compression_level 182 | )?; 183 | }else{ 184 | 0xffffffff_u32.wr(out)?; 185 | } 186 | this.online_score_id.wr(out)?; 187 | }); 188 | 189 | /// Represents a single action within a replay. 190 | /// The meaning of an action depends on the gamemode of the replay, but all actions 191 | /// contain: 192 | /// 193 | /// - An integral amount of milliseconds elapsed since the last action, `delta`. 194 | /// - 3 pieces of floating-point payload: `x`, `y` and `z`. 195 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 196 | #[derive(Debug, Clone, PartialEq)] 197 | pub struct Action { 198 | /// The amount of milliseconds since the last action. 199 | pub delta: i64, 200 | /// First bit of payload in the action. 201 | /// 202 | /// In standard: 203 | /// Represents the `x` coordinate of the mouse, from `0` to `512`. 204 | /// 205 | /// In mania: 206 | /// Represents the bitwise combination of buttons pressed. 207 | pub x: f32, 208 | /// Second bit of payload in the action. 209 | /// 210 | /// In standard: 211 | /// Represents the `y` coordinate of the mouse, from `0` to `384`. 212 | pub y: f32, 213 | /// Third bit of payload in the action. 214 | /// 215 | /// In standard: 216 | /// Represents the bitwise combination of buttons pressed. 217 | pub z: f32, 218 | } 219 | impl Action { 220 | /// Get the pressed osu!standard buttons. 221 | pub fn std_buttons(&self) -> StandardButtonSet { 222 | StandardButtonSet::from_bits(self.z as u32) 223 | } 224 | 225 | /// Get the pressed osu!mania buttons. 226 | pub fn mania_buttons(&self) -> ManiaButtonSet { 227 | ManiaButtonSet::from_bits(self.x as u32) 228 | } 229 | } 230 | 231 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 232 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] 233 | #[repr(u32)] 234 | pub enum StandardButton { 235 | MousePrimary, 236 | MouseSecondary, 237 | KeyPrimary, 238 | KeySecondary, 239 | } 240 | impl StandardButton { 241 | pub fn raw(&self) -> u32 { 242 | *self as u32 243 | } 244 | } 245 | 246 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] 247 | pub struct StandardButtonSet(pub u32); 248 | impl StandardButtonSet { 249 | pub fn bits(self) -> u32 { 250 | self.0 251 | } 252 | pub fn from_bits(bits: u32) -> StandardButtonSet { 253 | StandardButtonSet(bits) 254 | } 255 | 256 | /// Create a new button combination with no buttons pressed. 257 | pub fn none() -> StandardButtonSet { 258 | StandardButtonSet::from_bits(0) 259 | } 260 | 261 | /// Check whether the combination lists the button as pressed. 262 | pub fn is_down(&self, button: StandardButton) -> bool { 263 | self.bits().bit(button.raw() as u32) 264 | } 265 | 266 | /// Set the pressed status of the given button. 267 | pub fn set_down(&self, button: StandardButton, is_down: bool) -> StandardButtonSet { 268 | let mut bits = self.bits(); 269 | bits.set_bit(button.raw() as u32, is_down); 270 | StandardButtonSet::from_bits(bits) 271 | } 272 | /// Set the pressed status of a button to `true`. 273 | pub fn press(&self, button: StandardButton) -> StandardButtonSet { 274 | self.set_down(button, true) 275 | } 276 | /// Set the pressed status of a button to `false`. 277 | pub fn release(&self, button: StandardButton) -> StandardButtonSet { 278 | self.set_down(button, false) 279 | } 280 | } 281 | 282 | /// Any combination of mania buttons being pressed. 283 | /// 284 | /// Button indices start from `0`, and go left-to-right. 285 | /// Button indices outside the replay key count should never be down. 286 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] 287 | pub struct ManiaButtonSet(pub u32); 288 | impl ManiaButtonSet { 289 | pub fn bits(&self) -> u32 { 290 | self.0 291 | } 292 | pub fn from_bits(bits: u32) -> ManiaButtonSet { 293 | ManiaButtonSet(bits) 294 | } 295 | 296 | /// Create a new key combination with no keys pressed. 297 | pub fn none() -> ManiaButtonSet { 298 | ManiaButtonSet::from_bits(0) 299 | } 300 | 301 | /// Check whether a certain key is pressed. 302 | pub fn is_down(&self, button: u32) -> bool { 303 | self.bits().bit(button) 304 | } 305 | 306 | /// Set the pressed status of a key. 307 | pub fn set_down(&self, button: u32, is_down: bool) -> ManiaButtonSet { 308 | let mut bits = self.bits(); 309 | bits.set_bit(button, is_down); 310 | ManiaButtonSet::from_bits(bits) 311 | } 312 | /// Set the pressed status of a key to `true`. 313 | pub fn press(&self, button: u32) -> ManiaButtonSet { 314 | self.set_down(button, true) 315 | } 316 | /// Set the pressed status of a key to `false`. 317 | pub fn release(&self, button: u32) -> ManiaButtonSet { 318 | self.set_down(button, false) 319 | } 320 | } 321 | 322 | fn parse_replay_data(raw: Option<&[u8]>) -> Result>, Error> { 323 | #[cfg(feature = "compression")] 324 | { 325 | if let Some(raw) = raw { 326 | use xz2::{stream::Stream, write::XzDecoder}; 327 | 328 | let mut decoder = 329 | XzDecoder::new_stream(Vec::new(), Stream::new_lzma_decoder(u64::MAX)?); 330 | decoder.write_all(raw)?; 331 | let data = decoder.finish()?; 332 | let actions = actions(&data)?.1; 333 | return Ok(Some(actions)); 334 | } 335 | } 336 | Ok(None) 337 | } 338 | 339 | fn write_replay_data( 340 | actions: Option<&[Action]>, 341 | raw: Option<&[u8]>, 342 | out: &mut W, 343 | compression_level: u32, 344 | ) -> io::Result<()> { 345 | let mut raw = raw.as_deref(); 346 | let compress_buf: Vec; 347 | //Compress if it's enabled and available 348 | #[cfg(feature = "compression")] 349 | { 350 | if let Some(actions) = actions { 351 | use xz2::{ 352 | stream::{LzmaOptions, Stream}, 353 | write::XzEncoder, 354 | }; 355 | let mut encoder = XzEncoder::new_stream( 356 | Vec::new(), 357 | Stream::new_lzma_encoder(&LzmaOptions::new_preset(compression_level)?)?, 358 | ); 359 | for action in actions.iter() { 360 | action.wr(&mut encoder)?; 361 | } 362 | compress_buf = encoder.finish()?; 363 | raw = Some(&compress_buf[..]); 364 | } 365 | } 366 | let raw = raw.unwrap_or_default(); 367 | //Prefix the data with its length 368 | (raw.len() as u32).wr(out)?; 369 | out.write_all(raw)?; 370 | Ok(()) 371 | } 372 | 373 | // Parse the plaintext list of actions. 374 | fn actions(bytes: &[u8]) -> IResult<&[u8], Vec> { 375 | many0(action)(bytes) 376 | } 377 | 378 | fn action(bytes: &[u8]) -> IResult<&[u8], Action> { 379 | let (rem, delta) = number(bytes)?; 380 | let (rem, _tag) = tag(b"|")(rem)?; 381 | let (rem, x) = number(rem)?; 382 | let (rem, _tag) = tag(b"|")(rem)?; 383 | let (rem, y) = number(rem)?; 384 | let (rem, _tag) = tag(b"|")(rem)?; 385 | let (rem, z) = number(rem)?; 386 | let (rem, _tag) = tag(b",")(rem)?; 387 | 388 | let action = Action { 389 | delta: delta as i64, 390 | x: x as f32, 391 | y: y as f32, 392 | z: z as f32, 393 | }; 394 | 395 | Ok((rem, action)) 396 | } 397 | 398 | writer!(Action [this,out] { 399 | write!(out, "{}|{}|{}|{},", this.delta,this.x,this.y,this.z)?; 400 | }); 401 | 402 | // Parse a textually encoded decimal number. 403 | fn number(bytes: &[u8]) -> IResult<&[u8], f64> { 404 | let (rem, sign) = opt(tag(b"-"))(bytes)?; 405 | let (rem, whole) = take_while1(|b: u8| b.is_ascii_digit())(rem)?; 406 | let (rem, decimal) = opt(number_bytes)(rem)?; 407 | 408 | let mut num = 0.0; 409 | 410 | for byte in whole { 411 | num *= 10.0; 412 | num += (*byte - b'0') as f64; 413 | } 414 | 415 | if let Some(decimal) = decimal { 416 | let mut value = 1.0; 417 | 418 | for byte in decimal { 419 | value /= 10.0; 420 | num += (*byte - b'0') as f64 * value; 421 | } 422 | } 423 | 424 | if sign.is_some() { 425 | num *= -1.0 426 | } 427 | 428 | Ok((rem, num)) 429 | } 430 | 431 | fn number_bytes(bytes: &[u8]) -> IResult<&[u8], &[u8]> { 432 | let (rem, _tag) = tag(b".")(bytes)?; 433 | 434 | take_while(|b: u8| b.is_ascii_digit())(rem) 435 | } 436 | -------------------------------------------------------------------------------- /src/score.rs: -------------------------------------------------------------------------------- 1 | //! Parsing for the `scores.db` osu file, which contains partial replay data locally. 2 | 3 | use crate::{ 4 | prelude::*, 5 | replay::{replay, Replay}, 6 | }; 7 | 8 | /// A score database, usually coming from a `scores.db` file. 9 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 10 | #[derive(Debug, Clone, PartialEq)] 11 | pub struct ScoreList { 12 | pub version: u32, 13 | pub beatmaps: Vec, 14 | } 15 | impl ScoreList { 16 | /// Read a score database from its raw bytes. 17 | pub fn from_bytes(bytes: &[u8]) -> Result { 18 | scores(bytes).map(|(_rem, scores)| scores) 19 | } 20 | 21 | /// Read a score database from a `scores.db` file. 22 | pub fn from_file>(path: P) -> Result { 23 | Self::from_bytes(&fs::read(path)?) 24 | } 25 | 26 | /// Write the score database to an arbitrary writer. 27 | pub fn to_writer(&self, mut out: W) -> io::Result<()> { 28 | self.wr(&mut out) 29 | } 30 | 31 | /// Similar to `to_writer` but writes the scores to a file. 32 | pub fn save>(&self, path: P) -> io::Result<()> { 33 | self.to_writer(BufWriter::new(File::create(path)?)) 34 | } 35 | } 36 | 37 | /// The scores for a single beatmap. 38 | #[cfg_attr(feature = "ser-de", derive(Serialize, Deserialize))] 39 | #[derive(Debug, Clone, PartialEq)] 40 | pub struct BeatmapScores { 41 | /// The beatmap hash. 42 | /// Should be redundant with the individual replay hashes. 43 | pub hash: Option, 44 | /// All the scored replays for this beatmap. 45 | pub scores: Vec, 46 | } 47 | 48 | fn scores(bytes: &[u8]) -> Result<(&[u8], ScoreList), Error> { 49 | let (rem, version) = int(bytes)?; 50 | let (mut rem, len) = int(rem)?; 51 | let mut beatmaps = Vec::with_capacity(len as usize); 52 | 53 | for _ in 0..len { 54 | let (rem_, beatmap_scores) = beatmap_scores(rem)?; 55 | beatmaps.push(beatmap_scores); 56 | rem = rem_; 57 | } 58 | 59 | let list = ScoreList { version, beatmaps }; 60 | 61 | Ok((rem, list)) 62 | } 63 | 64 | fn beatmap_scores(bytes: &[u8]) -> Result<(&[u8], BeatmapScores), Error> { 65 | let (rem, hash) = opt_string(bytes)?; 66 | let (mut rem, len) = int(rem)?; 67 | let mut scores = Vec::with_capacity(len as usize); 68 | 69 | for _ in 0..len { 70 | let (rem_, replay) = replay(rem, false)?; 71 | rem = rem_; 72 | scores.push(replay); 73 | } 74 | 75 | let scores = BeatmapScores { hash, scores }; 76 | 77 | Ok((rem, scores)) 78 | } 79 | 80 | writer!(ScoreList [this,out] { 81 | this.version.wr(out)?; 82 | PrefixedList(&this.beatmaps).wr(out)?; 83 | }); 84 | writer!(BeatmapScores [this,out] { 85 | this.hash.wr(out)?; 86 | PrefixedList(&this.scores).wr_args(out,None)?; 87 | }); 88 | --------------------------------------------------------------------------------