commit 8319ee42d9ca23e6d60ad4c5b92efebf153cd272 Author: Simon Bruder Date: Mon Jun 22 20:33:12 2020 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd848f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/testfiles diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..72de9b6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,568 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "adler32" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2" + +[[package]] +name = "aho-corasick" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8716408b8bc624ed7f65d223ddb9ac2d044c0547b6fa4b0d554f3a9540496ada" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bb70cc08ec97ca5450e6eba421deeea5f172c0fc61f78b5357b2a8e8be195f" + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "brd" +version = "0.1.0" +dependencies = [ + "anyhow", + "byteorder", + "clap", + "log", + "nom", + "num-derive", + "num-traits", + "pretty_env_logger", + "zip", +] + +[[package]] +name = "byteorder" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" + +[[package]] +name = "bzip2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b7c3cbf0fa9c1b82308d57191728ca0256cb821220f4e2fd410a72ade26e3b" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.9+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad3b39a260062fca31f7b0b12f207e8f2590a67d32ec7d59c20484b07ea7285e" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.0.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbb73db36c1246e9034e307d0fba23f9a2e251faa47ade70c1bd252220c8311" + +[[package]] +name = "cfg-if" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b486ce3ccf7ffd79fdeb678eac06a9e6c09fc88d33836340becb8fffe87c5e33" + +[[package]] +name = "clap" +version = "3.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "860643c53f980f0d38a5e25dfab6c3c93b2cb3aa1fe192643d17a293c6c41936" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "indexmap", + "lazy_static", + "os_str_bytes", + "strsim", + "termcolor", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "clap_derive" +version = "3.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb51c9e75b94452505acd21d929323f5a5c6c4735a852adbd39ef5fb1b014f30" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "crc32fast" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba125de2af0df55319f41944744ad91c71113bf74a4646efff39afe1f6842db1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "flate2" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfff41391129e0a856d6d822600b8d71179d46879e310417eb9c762eb178b42" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] +name = "heck" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9586eedd4ce6b3c498bc3b4dd92fc9f11166aa908a914071953768066c67909" +dependencies = [ + "libc", +] + +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error", +] + +[[package]] +name = "indexmap" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c398b2b113b55809ceb9ee3e753fcbac793f1956663f3c36549c1346015c2afe" +dependencies = [ + "autocfg", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lexical-core" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86d66d380c9c5a685aaac7a11818bdfa1f733198dfd9ec09c70b762cd12ad6f" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "rustc_version", + "ryu", + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9457b06509d27052635f90d6466700c65095fdf75409b3fbdd903e988b886f49" + +[[package]] +name = "log" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" + +[[package]] +name = "miniz_oxide" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" +dependencies = [ + "adler32", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b471253da97532da4b61552249c521e01e736071f71c1a4f7ebbfbf0a06aad6" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + +[[package]] +name = "num-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8b15b261814f992e33760b1fca9fe8b693d8a65299f20c9901688636cfb746" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096" +dependencies = [ + "autocfg", +] + +[[package]] +name = "os_str_bytes" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06de47b848347d8c4c94219ad8ecd35eb90231704b067e67e6ae2e36ee023510" + +[[package]] +name = "pkg-config" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05da548ad6865900e60eaba7f589cc0783590a92e940c26953ff81ddbab2d677" + +[[package]] +name = "podio" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18befed8bc2b61abc79a457295e7e838417326da1586050b919414073977f19" + +[[package]] +name = "pretty_env_logger" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "syn-mid", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beae6331a816b1f65d04c45b078fd8e6c93e8071771f41b8163255bbd8d7c8fa" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3780fcf44b193bc4d09f36d2a3c87b251da4a046c87795a0d35f4f927ad8e6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", + "thread_local", +] + +[[package]] +name = "regex-syntax" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26412eb97c6b088a6997e05f69403a802a92d520de2f8e63c2b65f9e0f47c4e8" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "static_assertions" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f3eb36b47e512f8f1c9e3d10c2c1965bc992bd9cdb024fa581e2194501c83d3" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5304cfdf27365b7585c25d4af91b35016ed21ef88f17ced89c7093b43dba8b6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "syn-mid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be3539f6c128a931cf19dcee741c1af532c7fd387baa739c03dd2e96479338a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "termcolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thread_local" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "unicode-segmentation" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" + +[[package]] +name = "unicode-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" + +[[package]] +name = "unicode-xid" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + +[[package]] +name = "winapi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "zip" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df134e83b8f0f8153a094c7b0fd79dfebe437f1d76e7715afa18ed95ebe2fd7" +dependencies = [ + "bzip2", + "crc32fast", + "flate2", + "podio", + "time", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1ceb2c6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "brd" +version = "0.1.0" +authors = ["Simon Bruder "] +edition = "2018" + +[dependencies] +anyhow = "1.0.31" +byteorder = "1.3.4" +log = "0.4.8" +nom = "5.1.1" +num-derive = "0.3" +num-traits = "0.2" +pretty_env_logger = "0.4" +zip = "0.5.5" +clap = "3.0.0-beta.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..62e8fd6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License (ISC) + +Copyright 2020 Simon Bruder + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fd941d --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# BRD + +BRD is a tool for working with [*Dance Dance Revolution*][ddr] step charts and +wave banks. It currently supports conversion to [*osu!mania*][osu!mania] +beatmaps and extraction of sounds from wave banks. + +## Installation + +Currently this is not published as a crate so you either have to clone the +repository manually and run `cargo build --release` or you can use `cargo +install --git https://github.com/sbruder/brd` to install the binary without +cloning. + +## Modes + +### ddr2osu + +This converts DDR step charts (.ssq files) and the corresponding audio (from +.xwb files) to osu beatmaps (in an .osz container). + +Basic usage: + + brd ddr2osu -s file.ssq -x file.xwb -o file.osz --title "Song Title" --artist "Song Artist" + +To learn more about supported options run `brd ddr2osu --help` + +Batch conversion is possible with the included shell script `batch_convert.sh` +(usage guide at the top of the script). + +#### Known Problems + + * Since *osu!mania* does not support shock arrows, it either ignores them or + (by default) replaces them with a two-key combination (↑↓ or ←→); you can + change this with the (`--shock-action` option) + * Freezes do not work (I do not know how to get the start time yet) and + therefore are disabled in code (`ddr::ssq::FREEZE`) + +### unxwb + +This can list and extract sounds from XWB wave banks. It currently only +supports [ADPCM][ADPCM] sounds. +Basic Usage: + + brd unxwb file.xwb + brd unxwb -l file.xwb + +#### Known Problems + +If you want to extract sounds that are stored in other formats, you can use +[Luigi Auriemma’s unxwb][unxwb] (Ctrl+F unxwb). + +## About this project + +This is my first rust project. Don’t expect too much from the code in terms of +quality, robustness or idiomacity (especially regarding error handling). There +currently are no tests. + +Large portions of this tool would not have been possible without the following +resources: + + * [SaxxonPike][SaxxonPike]’s [scharfrichter][scharfrichter] which implements + [SSQ][scharfrichter-ssq] and XWB ([1][scharfrichter-xwb1], + [2][scharfrichter-xwb2]) and their [documentation about SSQ][ssq-doc] + * The [official osu! file format documentation][osu-doc] + * [MonoGame][MonoGame]’s [XWB implementation][MonoGame-xwb] + * [Luigi Auriemma][aluigi]’s [unxwb][unxwb] (especially the ADPCM header part) + +## License + +[ISC License](LICENSE) + +This project is not affiliated with ppy or Konami. + +[ADPCM]: https://en.wikipedia.org/wiki/Adaptive_differential_pulse-code_modulation +[MonoGame-xwb]: https://github.com/MonoGame/MonoGame/blob/develop/MonoGame.Framework/Audio/Xact/WaveBank.cs +[MonoGame]: https://github.com/MonoGame/MonoGame +[SaxxonPike]: https://github.com/SaxxonPike +[aluigi]: http://aluigi.altervista.org/ +[ddr]: https://en.wikipedia.org/wiki/Dance_Dance_Revolution +[osu!mania]: https://osu.ppy.sh/help/wiki/Game_Modes/osu%21mania +[osu-doc]: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format) +[scharfrichter-ssq]: https://github.com/SaxxonPike/scharfrichter/blob/master/Scharfrichter/Archives/BemaniSSQ.cs +[scharfrichter-xwb1]: https://github.com/SaxxonPike/scharfrichter/blob/master/Scharfrichter/Archives/MicrosoftXWB.cs +[scharfrichter-xwb2]: https://github.com/SaxxonPike/scharfrichter/blob/master/Scharfrichter/XACT3/Xact3WaveBank.cs +[scharfrichter]: https://github.com/SaxxonPike/scharfrichter +[ssq-doc]: https://github.com/SaxxonPike/rhythm-game-formats/blob/master/ddr/ssq.md +[unxwb]: http://aluigi.altervista.org/papers.htm diff --git a/batch_convert.sh b/batch_convert.sh new file mode 100755 index 0000000..28181e8 --- /dev/null +++ b/batch_convert.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Batch conversion helper script +# +# Create a new directory with two child directories (ssq and xwb). Put the step +# charts (*.ssq) into ssq and the wave banks (*.xwb) into xwb. If the directory +# is a child of this project, you can directly run the script, otherwise you +# have to execute it with the environment variable RUN_COMMAND that points to +# the executable. If you want to specify additional flags to pass to brd you +# can just pass them to this script. The converted beatmaps will be placed in +# the osz directory. +# If you want your beatmaps to have the right metadata (title and artist), +# create a file “metadata.csv” in the directory where you run this script with +# the following structure: name,Title,Artist (name is the name of the ssq file +# without the extension) +# +# Example: +# $ RUN_COMMAND=/path/to/target/release/brd /path/to/batch_covert.sh --source "Dance Dance Revolution x3" + +set -e + +mkdir -p osz + +RUN_COMMAND=${RUN_COMMAND:-"cargo run --release --"} + +echo "Extracting wave bank sound names" +for i in xwb/*.xwb; do + outfile="xwb/$(basename $i .xwb).xwb.sounds" + if ! [ -f "$outfile" ]; then + $RUN_COMMAND unxwb -l $i > "$outfile" + fi +done + +for ssq_file in ssq/*.ssq; do + name=$(basename $ssq_file .ssq) + + if [ -f "xwb/${name}.xwb" ]; then + xwb_file="xwb/${name}.xwb" + else + xwb_file="$(grep -lE "^${name}$" xwb/*.xwb.sounds|head -n 1)" + # strip .sounds + xwb_file="${xwb_file%.*}" + if [ -z "$xwb_file" ]; then + echo "ERR: Could not find wave bank for $name" >&2 + continue + fi + fi + + metadata=$(grep -sE "^${name}," metadata.csv|head -n 1) + if ! [ -z "$metadata" ]; then + title="$(cut -d, -f2 <<< $metadata)" + artist="$(cut -d, -f3 <<< $metadata)" + else + title="$name" + artist="unknown artist" + fi + + echo "Converting $name" + $RUN_COMMAND ddr2osu -s "$ssq_file" -x "$xwb_file" -o "osz/${name}.osz" --title "$title" --artist "$artist" "$@" +done diff --git a/src/converter.rs b/src/converter.rs new file mode 100644 index 0000000..285285c --- /dev/null +++ b/src/converter.rs @@ -0,0 +1 @@ +pub mod ddr2osu; diff --git a/src/converter/ddr2osu.rs b/src/converter/ddr2osu.rs new file mode 100644 index 0000000..2b58319 --- /dev/null +++ b/src/converter/ddr2osu.rs @@ -0,0 +1,421 @@ +use std::fmt; +use std::str::FromStr; + +use anyhow::{anyhow, Result}; +use clap::Clap; +use log::{debug, info, trace}; + +use crate::ddr::ssq; +use crate::osu::beatmap; + +#[derive(Debug)] +pub struct ConfigRange(f32, f32); + +impl ConfigRange { + /// Map value from 0 to 1 onto the range + fn map_from(&self, value: f32) -> f32 { + (value * (self.1 - self.0)) + self.0 + } +} + +impl fmt::Display for ConfigRange { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}:{}", self.0, self.1) + } +} + +impl FromStr for ConfigRange { + type Err = anyhow::Error; + + fn from_str(string: &str) -> Result { + match string.split(':').collect::>()[..] { + [start, end] => Ok(ConfigRange(start.parse::()?, end.parse::()?)), + _ => Err(anyhow!("Invalid range format (expected start:end)")), + } + } +} + +#[derive(Debug, Clap)] +pub struct Config { + #[clap(skip = "audio.wav")] + pub audio_filename: String, + #[clap( + long, + default_value = "180", + about = "Offset in milliseconds", + display_order = 5 + )] + pub offset: i32, + #[clap( + long = "no-stops", + about = "Disable stops", + parse(from_flag = std::ops::Not::not), + display_order = 5 + )] + pub stops: bool, + #[clap( + arg_enum, + long, + default_value = "step", + about = "What to do with shocks", + display_order = 5 + )] + pub shock_action: ShockAction, + #[clap( + long = "hp", + about = "Range of HP drain (beginner:challenge)", + default_value = "4:8" + )] + pub hp_drain: ConfigRange, + #[clap( + long = "acc", + about = "Range of Accuracy (beginner:challenge)", + default_value = "7:8" + )] + pub accuracy: ConfigRange, + #[clap(flatten)] + pub metadata: ConfigMetadata, +} + +#[derive(Clap, Debug)] +pub struct ConfigMetadata { + #[clap(long, about = "Song title to use in beatmap", display_order = 6)] + pub title: String, + #[clap(long, about = "Artist name to use in beatmap", display_order = 6)] + pub artist: String, + #[clap( + long, + default_value = "Dance Dance Revolution", + about = "Source to use in beatmap", + display_order = 6 + )] + pub source: String, +} + +impl fmt::Display for Config { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "ddr2osu (+{}ms{} shock→{:?} hp{} acc{})", + self.offset, + if self.stops { " stops" } else { "" }, + self.shock_action, + self.hp_drain, + self.accuracy + ) + } +} + +#[derive(Clap, Clone, Debug)] +pub enum ShockAction { + Ignore, + Step, + //Static(Vec), +} + +struct ShockStepGenerator { + last: u8, + columns: u8, + mode: ShockAction, +} + +impl Iterator for ShockStepGenerator { + type Item = Vec; + + fn next(&mut self) -> Option> { + match &self.mode { + ShockAction::Ignore => None, + ShockAction::Step => { + let columns = match self.last { + 0 | 3 => vec![0, 3], + 1 | 2 => vec![1, 2], + 4 | 7 => vec![4, 7], + 5 | 6 => vec![5, 6], + _ => vec![], + }; + self.last = (self.last + 1) % self.columns; + Some(columns) + } //ShockAction::Static(columns) => Some(columns.clone()), + } + } +} + +impl ShockStepGenerator { + fn new(columns: u8, mode: ShockAction) -> Self { + Self { + last: 0, + columns, + mode, + } + } +} + +fn get_time_from_beats(beats: f32, tempo_changes: &[ssq::TempoChange]) -> Result { + for tempo_change in tempo_changes { + // For TempoChanges that are infinitely short but exactly cover that beat, use the start + // time of that TempoChange + if (beats - tempo_change.start_beats).abs() < 0.001 + && (beats - tempo_change.end_beats).abs() < 0.001 + { + return Ok(tempo_change.start_ms); + } + + if beats < tempo_change.end_beats { + return Ok(tempo_change.start_ms + + ((beats - tempo_change.start_beats) * tempo_change.beat_length) as i32); + } + } + + Err(anyhow!( + "Conversion of Step to HitObject failed: Beat lies outside of TimingPoints range" + )) +} + +impl From for beatmap::TimingPoint { + fn from(tempo_change: ssq::TempoChange) -> Self { + beatmap::TimingPoint { + time: tempo_change.start_ms, + beat_length: if tempo_change.beat_length == f32::INFINITY { + 10000.0 + } else { + tempo_change.beat_length + }, + meter: 4, + sample_set: beatmap::SampleSet::BeatmapDefault, + sample_index: 0, + volume: 100, + uninherited: true, + effects: beatmap::TimingPointEffects { + kiai_time: false, + omit_first_barline: false, + }, + } + } +} + +impl ssq::Step { + fn to_hit_objects( + &self, + num_columns: u8, + tempo_changes: &ssq::TempoChanges, + shock_step_generator: &mut ShockStepGenerator, + ) -> Result> { + let mut hit_objects = Vec::new(); + + match self { + ssq::Step::Step { beats, row } => { + let time = get_time_from_beats(*beats, &tempo_changes.0)?; + + let columns: Vec = row.clone().into(); + + for (column, active) in columns.iter().enumerate() { + if *active { + hit_objects.push(beatmap::HitObject::HitCircle { + x: beatmap::column_to_x(column as u8, num_columns), + y: 192, + time, + hit_sound: beatmap::HitSound { + normal: true, + whistle: false, + finish: false, + clap: false, + }, + new_combo: false, + skip_combo_colours: 0, + hit_sample: beatmap::HitSample { + normal_set: 0, + addition_set: 0, + index: 0, + volume: 0, + filename: "".to_string(), + }, + }) + } + } + } + ssq::Step::Freeze { start, end, row } => { + let time = get_time_from_beats(*start, &tempo_changes.0)?; + let end_time = get_time_from_beats(*end, &tempo_changes.0)?; + + let columns: Vec = row.clone().into(); + + for (column, active) in columns.iter().enumerate() { + if *active { + hit_objects.push(beatmap::HitObject::Hold { + column: column as u8, + columns: num_columns, + time, + end_time, + hit_sound: beatmap::HitSound { + normal: true, + whistle: false, + finish: true, + clap: false, + }, + new_combo: false, + skip_combo_colours: 0, + hit_sample: beatmap::HitSample { + normal_set: 0, + addition_set: 0, + index: 0, + volume: 0, + filename: "".to_string(), + }, + }) + } + } + } + ssq::Step::Shock { beats } => { + let columns = match shock_step_generator.next() { + Some(columns) => columns, + None => vec![], + }; + + for column in columns { + hit_objects.push(beatmap::HitObject::HitCircle { + x: beatmap::column_to_x(column as u8, num_columns), + y: 192, + time: get_time_from_beats(*beats, &tempo_changes.0)?, + hit_sound: beatmap::HitSound { + normal: true, + whistle: false, + finish: false, + clap: false, + }, + new_combo: false, + skip_combo_colours: 0, + hit_sample: beatmap::HitSample { + normal_set: 0, + addition_set: 0, + index: 0, + volume: 0, + filename: "".to_string(), + }, + }) + } + } + } + + Ok(hit_objects) + } +} + +struct ConvertedChart { + difficulty: ssq::Difficulty, + hit_objects: beatmap::HitObjects, + timing_points: beatmap::TimingPoints, +} + +impl ConvertedChart { + fn to_beatmap(&self, config: &Config) -> beatmap::Beatmap { + beatmap::Beatmap { + version: 14, + general: beatmap::General { + audio_filename: config.audio_filename.clone(), + audio_lead_in: 0, + preview_time: 0, + countdown: beatmap::Countdown::No, + sample_set: beatmap::SampleSet::Soft, + mode: beatmap::Mode::Mania, + }, + editor: beatmap::Editor {}, + metadata: beatmap::Metadata { + title: config.metadata.title.clone(), + artist: config.metadata.artist.clone(), + creator: format!("{}", config), + version: format!("{}", self.difficulty), + source: config.metadata.source.clone(), + tags: vec![], + }, + difficulty: beatmap::Difficulty { + hp_drain_rate: config.hp_drain.map_from(self.difficulty.clone().into()), + circle_size: self.difficulty.players as f32 * 4.0, + overall_difficulty: config.accuracy.map_from(self.difficulty.clone().into()), + approach_rate: 8.0, + slider_multiplier: 0.64, + slider_tick_rate: 1.0, + }, + events: beatmap::Events(vec![]), + timing_points: self.timing_points.clone(), + colours: beatmap::Colours(vec![]), + hit_objects: self.hit_objects.clone(), + } + } +} + +impl ssq::SSQ { + pub fn to_beatmaps(&self, config: &Config) -> Result> { + debug!("Configuration: {:?}", config); + + let mut timing_points = Vec::new(); + + timing_points.push(beatmap::TimingPoint { + time: 0, + beat_length: config.offset as f32, + meter: 4, + sample_set: beatmap::SampleSet::BeatmapDefault, + sample_index: 0, + volume: 0, + uninherited: true, + effects: beatmap::TimingPointEffects { + kiai_time: false, + omit_first_barline: false, + }, + }); + + for entry in &self.tempo_changes.0 { + if config.stops || entry.beat_length != f32::INFINITY { + trace!("Converting {:?} to to timing point", entry); + let timing_point: beatmap::TimingPoint = entry.clone().into(); + timing_points.push(timing_point); + } + } + debug!( + "Converted {} tempo changes to timing points", + self.tempo_changes.0.len() + ); + + let mut converted_charts = Vec::new(); + + for chart in &self.charts { + debug!("Converting chart {} to beatmap", chart.difficulty); + let mut hit_objects = beatmap::HitObjects(Vec::new()); + + let mut shock_step_generator = + ShockStepGenerator::new(chart.difficulty.players * 4, config.shock_action.clone()); + for step in &chart.steps.0 { + trace!("Converting {:?} to hit object", step); + let mut step_hit_objects = step.to_hit_objects( + chart.difficulty.players * 4, + &self.tempo_changes, + &mut shock_step_generator, + )?; + hit_objects.0.append(&mut step_hit_objects); + } + + let converted_chart = ConvertedChart { + difficulty: chart.difficulty.clone(), + hit_objects, + timing_points: beatmap::TimingPoints(timing_points.clone()), + }; + + debug!( + "Converted to beatmap with {} hit objects", + converted_chart.hit_objects.0.len(), + ); + + converted_charts.push(converted_chart); + } + + let mut beatmaps = Vec::new(); + + for converted_chart in converted_charts { + let beatmap = converted_chart.to_beatmap(config); + beatmaps.push(beatmap); + } + + info!("Converted {} step charts to beatmaps", beatmaps.len()); + + Ok(beatmaps) + } +} diff --git a/src/ddr.rs b/src/ddr.rs new file mode 100644 index 0000000..5ec8878 --- /dev/null +++ b/src/ddr.rs @@ -0,0 +1 @@ +pub mod ssq; diff --git a/src/ddr/ssq.rs b/src/ddr/ssq.rs new file mode 100644 index 0000000..665ad3b --- /dev/null +++ b/src/ddr/ssq.rs @@ -0,0 +1,420 @@ +use std::convert::From; +use std::fmt; + +use anyhow::{anyhow, Result}; +use log::{debug, info, trace, warn}; +use nom::bytes::complete::take; +use nom::multi::many0; +use nom::number::complete::{le_i16, le_i32, le_u16}; +use nom::IResult; + +use crate::utils; +use crate::utils::exec_nom_parser; + +const MEASURE_LENGTH: i32 = 4096; +const FREEZE: bool = false; + +// Convert time offset to beats +// time offset is the measure times MEASURE_LENGTH +fn measure_to_beats(metric: i32) -> f32 { + 4.0 * metric as f32 / MEASURE_LENGTH as f32 +} + +fn parse_n_i32(n: usize, input: &[u8]) -> IResult<&[u8], Vec> { + let (input, bytes) = take(n as usize * 4)(input)?; + let (unprocessed_input, values) = many0(le_i32)(bytes)?; + assert_eq!(unprocessed_input.len(), 0); + Ok((input, values)) +} + +fn parse_usize(input: &[u8]) -> IResult<&[u8], usize> { + let (input, value) = le_i32(input)?; + Ok((input, value as usize)) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TempoChange { + pub start_ms: i32, + pub start_beats: f32, + pub end_beats: f32, + pub beat_length: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TempoChanges(pub Vec); + +impl TempoChanges { + fn parse(ticks_per_second: i32, input: &[u8]) -> IResult<&[u8], Self> { + let (input, count) = parse_usize(input)?; + let (input, measure) = parse_n_i32(count, input)?; + let (input, tempo_data) = parse_n_i32(count, input)?; + + let mut entries = Vec::new(); + + let mut elapsed_ms = 0; + let mut elapsed_beats = 0.0; + for i in 1..count { + let delta_measure = measure[i] - measure[i - 1]; + let delta_ticks = tempo_data[i] - tempo_data[i - 1]; + + let length_ms = 1000 * delta_ticks / ticks_per_second; + let length_beats = measure_to_beats(delta_measure); + + let beat_length = length_ms as f32 / length_beats; + + let entry = TempoChange { + start_ms: elapsed_ms, + start_beats: elapsed_beats, + end_beats: elapsed_beats + length_beats, + beat_length, + }; + + entries.push(entry); + + elapsed_ms += length_ms; + elapsed_beats += length_beats; + } + + Ok((input, Self(entries))) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Step { + Step { beats: f32, row: Row }, + Freeze { start: f32, end: f32, row: Row }, + Shock { beats: f32 }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Steps(pub Vec); + +impl Steps { + fn parse(input: &[u8], players: u8) -> IResult<&[u8], Self> { + let (input, count) = parse_usize(input)?; + let (input, measure) = parse_n_i32(count, input)?; + let (input, steps) = take(count)(input)?; + + // freeze data can be padded with zeroes + let (input, freeze_data) = take(input.len())(input)?; + let mut freeze = freeze_data.iter().skip_while(|x| **x == 0).copied(); + + let mut parsed_steps = Vec::new(); + + for i in 0..count { + let beats = measure_to_beats(measure[i]); + + // check if either all eight bits are set (shock for double) or the first four (shock for + // single) + if steps[i] == 0xff || steps[i] == 0xf { + // shock + trace!("Shock arrow at {}", beats); + + parsed_steps.push(Step::Shock { beats }); + } else if steps[i] == 0x00 { + // extra data + let columns = freeze.next().unwrap(); + let extra_type = freeze.next().unwrap(); + + if extra_type == 1 { + // freeze end (start is the last normal step in that column) + trace!("Freeze arrow at {}", beats); + + let row = Row::new(columns, players); + if row.count_active() != 1 { + warn!("Found freeze with not exactly one column, which is not implemented, skipping"); + continue; + } + + let last_step = match Self::find_last(Self(parsed_steps.clone()), &row) { + Ok(last_step) => last_step, + Err(err) => { + warn!("Could not add freeze arrow: {}; adding normal step", err); + parsed_steps.push(Step::Step { beats, row }); + continue; + } + }; + + if FREEZE { + parsed_steps.push(Step::Freeze { + start: if let Step::Step { beats, .. } = parsed_steps[last_step] { + beats + } else { + unreachable!() + }, + end: beats, + row, + }); + + parsed_steps.remove(last_step); + } else { + trace!("Freeze disabled, adding normal step"); + parsed_steps.push(Step::Step { beats, row }); + } + } else { + debug!( + "Encountered unknown extra step with type {}, ignoring", + extra_type + ); + } + } else { + // normal step + trace!("Normal step at {}", beats); + + parsed_steps.push(Step::Step { + beats, + row: Row::new(steps[i], players), + }); + } + } + + debug!("Parsed {} steps", parsed_steps.len()); + + Ok((input, Self(parsed_steps))) + } + + fn find_last(steps: Self, row: &Row) -> Result { + for i in (0..steps.0.len()).rev() { + if let Step::Step { row: step_row, .. } = &steps.0[i] { + if step_row.clone().intersects(row.clone()) { + return Ok(i); + } + } + } + + Err(anyhow!("No previous step found on that column")) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Difficulty { + pub players: u8, + difficulty: u8, +} + +impl From for Difficulty { + fn from(parameter: u16) -> Self { + Self { + difficulty: ((parameter & 0xFF00) >> 8) as u8, + players: (parameter & 0xF) as u8 / 4, + } + } +} + +impl Into for Difficulty { + fn into(self) -> f32 { + match self.difficulty { + 1 => 0.25, + 2 => 0.5, + 3 => 1.0, + 4 => 0.0, + 6 => 0.75, + _ => 1.0, + } + } +} + +impl fmt::Display for Difficulty { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let players = match self.players { + 1 => "Single", + 2 => "Double", + _ => "Unknown Number of Players", + }; + let difficulty = match self.difficulty { + 1 => "Basic", + 2 => "Difficult", + 3 => "Challenge", + 4 => "Beginner", + 6 => "Expert", + _ => "Unknown Difficulty", + }; + write!(f, "{} {}", players, difficulty) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Chart { + pub difficulty: Difficulty, + pub steps: Steps, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SSQ { + pub tempo_changes: TempoChanges, + pub charts: Vec, +} + +impl From for SSQ { + fn from(chunks: Chunks) -> Self { + let mut ssq = Self { + tempo_changes: TempoChanges(Vec::new()), + charts: Vec::new(), + }; + for chunk in chunks.0 { + match chunk { + Chunk::TempoChanges(mut tempo_changes) => { + ssq.tempo_changes.0.append(&mut tempo_changes.0) + } + Chunk::Chart(chart) => ssq.charts.push(chart), + Chunk::Extra(..) => {} + } + } + info!("Parsed {} charts", ssq.charts.len()); + ssq + } +} + +impl SSQ { + pub fn parse(data: &[u8]) -> Result { + debug!( + "Configuration: measure length: {}, use freezes: {}", + MEASURE_LENGTH, FREEZE + ); + let chunks = exec_nom_parser(Chunks::parse, data)?; + + Ok(Self::from(chunks)) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum Chunk { + Chart(Chart), + TempoChanges(TempoChanges), + Extra(Vec), +} + +impl Chunk { + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, length) = le_i32(input)?; + + let (input, chunk_type) = le_i16(input)?; + let (input, parameter) = le_u16(input)?; + + // length without i32 and 2 × i16 + let (input, data) = take(length as usize - 8)(input)?; + + let chunk = match chunk_type { + 1 => { + debug!("Parsing tempo changes (ticks/s: {})", parameter); + let (_, TempoChanges(tempo_changes)) = TempoChanges::parse(parameter as i32, data)?; + Self::TempoChanges(TempoChanges(tempo_changes)) + } + 3 => { + let difficulty = Difficulty::from(parameter); + debug!("Parsing step chunk ({})", difficulty); + let (_, steps) = Steps::parse(data, difficulty.players)?; + Self::Chart(Chart { difficulty, steps }) + } + _ => { + debug!("Found extra chunk (length {})", data.len()); + Self::Extra(data.to_vec()) + } + }; + Ok((input, chunk)) + } +} + +pub struct Chunks(Vec); + +impl Chunks { + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, chunks) = many0(Chunk::parse)(input)?; + Ok((input, Self(chunks))) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PlayerRow { + pub left: bool, + pub down: bool, + pub up: bool, + pub right: bool, +} + +impl From for PlayerRow { + fn from(byte: u8) -> Self { + let columns = utils::byte_to_bitarray(byte); + PlayerRow { + left: columns[0], + down: columns[1], + up: columns[2], + right: columns[3], + } + } +} + +impl Into> for PlayerRow { + fn into(self) -> Vec { + vec![self.left, self.down, self.up, self.right] + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Row { + Single(PlayerRow), + Double(PlayerRow, PlayerRow), +} + +impl Into> for Row { + fn into(self) -> Vec { + match self { + Self::Single(row) => row.into(), + Self::Double(row1, row2) => { + let mut row: Vec = Vec::new(); + row.append(&mut row1.into()); + row.append(&mut row2.into()); + row + } + } + } +} + +impl Row { + fn new(byte: u8, players: u8) -> Self { + match players { + 1 => Self::Single(PlayerRow::from(byte)), + 2 => Self::Double(PlayerRow::from(byte), PlayerRow::from(byte >> 4)), + _ => unreachable!(), + } + } + + fn count_active(&self) -> u8 { + let mut rows = Vec::::new(); + + match self { + Self::Single(row) => { + rows.append(&mut row.clone().into()); + } + Self::Double(player1, player2) => { + rows.append(&mut player1.clone().into()); + rows.append(&mut player2.clone().into()); + } + } + + rows.iter().map(|x| *x as u8).sum() + } + + fn intersects(self, other: Self) -> bool { + let rows: Vec<(Vec, Vec)> = match (self, other) { + (Self::Single(self_row), Self::Single(other_row)) => { + vec![(self_row.into(), other_row.into())] + } + (Self::Double(self_row1, self_row2), Self::Double(other_row1, other_row2)) => vec![ + (self_row1.into(), other_row1.into()), + (self_row2.into(), other_row2.into()), + ], + _ => vec![], + }; + + for (self_row, other_row) in rows { + for (self_col, other_col) in self_row.iter().zip(other_row.iter()) { + if *self_col && self_col == other_col { + return true; + } + } + } + + false + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ba7f2ae --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod converter; +pub mod ddr; +pub mod osu; +mod utils; +pub mod xact3; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..add2fd8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,179 @@ +use std::fs::File; +use std::io::prelude::*; +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use clap::Clap; +use log::{debug, error, info}; + +use brd::converter; +use brd::ddr::ssq::SSQ; +use brd::osu; +use brd::xact3::xwb::WaveBank; + +#[derive(Clap)] +#[clap()] +struct Opts { + #[clap(subcommand)] + subcmd: SubCommand, +} + +#[derive(Clap)] +enum SubCommand { + #[clap( + name = "unxwb", + about = "Extracts sounds from XWB wave banks", + display_order = 1 + )] + UnXWB(UnXWB), + #[clap( + about = "Converts DDR step charts to osu!mania beatmaps", + display_order = 1 + )] + DDR2osu(DDR2osu), +} + +#[derive(Clap)] +struct UnXWB { + #[clap(short, long, about = "List available sounds and exit")] + list_entries: bool, + #[clap(short = "e", long, about = "Only extract this entry")] + single_entry: Option, + #[clap(name = "file")] + file: PathBuf, +} + +#[derive(Clap)] +struct DDR2osu { + #[clap( + short = "s", + long = "ssq", + name = "file.ssq", + about = "DDR step chart file", + display_order = 1 + )] + ssq_file: PathBuf, + #[clap( + short = "x", + long = "xwb", + name = "file.xwb", + about = "XAC3 wave bank file", + display_order = 2 + )] + xwb_file: PathBuf, + #[clap( + short = "o", + long = "out", + name = "file.osz", + about = "osu! beatmap archive", + display_order = 3 + )] + out_file: PathBuf, + #[clap( + short = "n", + name = "sound name", + about = "Sound in wave bank, otherwise inferred from SSQ filename", + display_order = 4 + )] + sound_name: Option, + #[clap(flatten)] + convert: converter::ddr2osu::Config, +} + +fn error(message: String) -> Result<()> { + error!("{}", message); + Err(anyhow!(message)) +} + +fn read_file(name: &PathBuf) -> Result> { + let mut file = File::open(name)?; + let mut data = vec![]; + file.read_to_end(&mut data)?; + Ok(data) +} + +fn get_basename(path: &PathBuf) -> Option<&str> { + match path.file_stem() { + Some(stem) => stem.to_str(), + None => None, + } +} + +fn main() -> Result<()> { + pretty_env_logger::init(); + + let opts: Opts = Opts::parse(); + + match opts.subcmd { + SubCommand::UnXWB(opts) => { + let xwb_data = read_file(&opts.file)?; + let wave_bank = WaveBank::parse(&xwb_data)?; + info!("Opened wave bank “{}” from {:?}", wave_bank.name, opts.file); + + match opts.single_entry { + Some(name) => { + let sound = match wave_bank.sounds.get(&name) { + Some(sound) => sound, + None => return error(format!("Entry {} not found in wave bank", name)), + }; + let out_file = format!("{}.wav", name); + let mut wav_file = File::create(out_file)?; + wav_file.write_all(&sound.to_wav()?)?; + } + None => { + for (name, sound) in wave_bank.sounds { + if opts.list_entries { + println!("{}", name); + continue; + } + info!("Extracting {}", name); + let out_file = format!("{}.wav", name); + let mut wav_file = File::create(out_file)?; + wav_file.write_all(&sound.to_wav()?)?; + } + } + } + } + SubCommand::DDR2osu(opts) => { + let sound_name = &opts + .sound_name + .unwrap_or(match get_basename(&opts.ssq_file) { + Some(basename) => basename.to_string(), + None => return error( + "Could not extract chart id from file name. Please specify it manually." + .to_string(), + ), + }); + + debug!( + "Converting {:?} and sound {} from {:?} to {:?}", + opts.ssq_file, sound_name, opts.xwb_file, opts.out_file + ); + + let ssq_data = read_file(&opts.ssq_file)?; + let ssq = SSQ::parse(&ssq_data)?; + + let convert_config = opts.convert; + let beatmaps = ssq.to_beatmaps(&convert_config)?; + + let xwb_data = read_file(&opts.xwb_file)?; + let wave_bank = WaveBank::parse(&xwb_data)?; + + let audio_data = if wave_bank.sounds.contains_key(sound_name) { + wave_bank.sounds.get(sound_name).unwrap().to_wav()? + } else { + return error(format!( + "Could not find sound with chart name {} in wave bank", + sound_name, + )); + }; + + let osz = osu::osz::Archive { + beatmaps, + assets: vec![("audio.wav", &audio_data)], + }; + osz.write(&opts.out_file)?; + } + } + Ok(()) +} diff --git a/src/osu.rs b/src/osu.rs new file mode 100644 index 0000000..ccbacce --- /dev/null +++ b/src/osu.rs @@ -0,0 +1,7 @@ +/* + * For documentation on the file format see + * https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format) + */ + +pub mod beatmap; +pub mod osz; diff --git a/src/osu/beatmap.rs b/src/osu/beatmap.rs new file mode 100644 index 0000000..62dc758 --- /dev/null +++ b/src/osu/beatmap.rs @@ -0,0 +1,595 @@ +use num_derive::ToPrimitive; +use num_traits::ToPrimitive; +use std::fmt; + +// Generic Type Aliases +pub type OsuPixel = i16; +pub type DecimalOsuPixel = f32; + +pub type SampleIndex = u16; + +pub type Time = i32; + +// Helper functions +fn bitflags(flags: [bool; 8]) -> u8 { + let mut value = 0u8; + for (i, flag) in flags.iter().enumerate() { + value += ((0b1 as u8) << i) * (*flag as u8) as u8; + } + value +} + +fn join_display_values(iterable: Vec, separator: &'_ str) -> String { + iterable + .iter() + .map(|val| val.to_string()) + .collect::>() + .join(&separator) +} + +fn assemble_hit_object_type(hit_object_type: u8, new_combo: bool, skip_combo_colours: U3) -> u8 { + let hit_object_type = 1u8 << hit_object_type; + let new_combo = if new_combo { 0b0000_0010_u8 } else { 0u8 }; + let skip_combo_colours = (skip_combo_colours & 0b_0000_0111u8) << 1; + hit_object_type + new_combo + skip_combo_colours +} + +pub fn column_to_x(column: u8, columns: u8) -> OsuPixel { + ((512 * column as OsuPixel + 256) / columns as OsuPixel) as OsuPixel +} + +#[derive(ToPrimitive, Clone)] +pub enum Countdown { + No = 0, + Normal = 1, + Half = 2, + Double = 3, +} + +#[derive(ToPrimitive, Clone)] +pub enum Mode { + Normal = 0, + Taiko = 1, + Catch = 2, + Mania = 3, +} + +#[derive(ToPrimitive, Debug, Clone)] +pub enum SampleSet { + BeatmapDefault = 0, + Normal = 1, + Soft = 2, + Drum = 3, +} + +#[derive(Clone)] +pub struct General { + pub audio_filename: String, + pub audio_lead_in: Time, + pub preview_time: Time, + pub countdown: Countdown, + pub sample_set: SampleSet, + pub mode: Mode, +} + +impl fmt::Display for General { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [General]\n\ + AudioFilename: {}\n\ + AudioLeadIn: {}\n\ + PreviewTime: {}\n\ + Countdown: {}\n\ + SampleSet: {:?}\n\ + Mode: {}\n\ + ", + self.audio_filename, + self.audio_lead_in, + self.preview_time, + ToPrimitive::to_u8(&self.countdown).unwrap(), + self.sample_set, + ToPrimitive::to_u8(&self.mode).unwrap() + ) + } +} + +#[derive(Clone)] +pub struct Editor {/* stub */} + +impl fmt::Display for Editor { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, "[Editor]") + } +} + +#[derive(Clone)] +pub struct Metadata { + pub title: String, + pub artist: String, + pub creator: String, + pub version: String, + pub source: String, + pub tags: Vec, +} + +impl fmt::Display for Metadata { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [Metadata]\n\ + Title:{}\n\ + Artist:{}\n\ + Creator:{}\n\ + Version:{}\n\ + Source:{}\n\ + Tags:{}\n\ + ", + self.title, + self.artist, + self.creator, + self.version, + self.source, + self.tags.join(" ") + ) + } +} + +#[derive(Clone)] +pub struct Difficulty { + pub hp_drain_rate: f32, + /// Also is the number of keys in mania + pub circle_size: f32, + pub overall_difficulty: f32, + pub approach_rate: f32, + pub slider_multiplier: f32, + pub slider_tick_rate: f32, +} + +impl fmt::Display for Difficulty { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [Difficulty]\n\ + HPDrainRate:{}\n\ + CircleSize:{}\n\ + OverallDifficulty:{}\n\ + ApproachRate:{}\n\ + SliderMultiplier:{}\n\ + SliderTickRate:{}\n\ + ", + self.hp_drain_rate, + self.circle_size, + self.overall_difficulty, + self.approach_rate, + self.slider_multiplier, + self.slider_tick_rate + ) + } +} + +#[derive(Clone)] +pub struct Events(pub Vec); + +impl fmt::Display for Events { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [Events]\n\ + {}\n\ + ", + join_display_values(self.0.clone(), "\n") + ) + } +} + +#[derive(Clone)] +pub enum Event { + Background { + filename: String, + x_offset: OsuPixel, + y_offset: OsuPixel, + }, + Video { + start_time: Time, + filename: String, + x_offset: OsuPixel, + y_offset: OsuPixel, + }, + Break { + start_time: Time, + end_time: Time, + }, +} + +impl fmt::Display for Event { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Event::Background { + filename, + x_offset, + y_offset, + } => write!(f, "0,0,{},{},{}", filename, x_offset, y_offset), + Event::Video { + start_time, + filename, + x_offset, + y_offset, + } => write!( + f, + "Video,{},{},{},{}", + start_time, filename, x_offset, y_offset + ), + Event::Break { + start_time, + end_time, + } => write!(f, "Break,{},{}", start_time, end_time), + } + } +} + +#[derive(Clone)] +pub struct TimingPoints(pub Vec); + +impl fmt::Display for TimingPoints { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [TimingPoints]\n\ + {}\n\ + ", + join_display_values(self.0.clone(), "\n") + ) + } +} + +#[derive(Clone)] +pub struct TimingPointEffects { + pub kiai_time: bool, + pub omit_first_barline: bool, +} + +impl fmt::Display for TimingPointEffects { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + bitflags([ + self.kiai_time, + false, + false, + self.omit_first_barline, + false, + false, + false, + false + ]) + ) + } +} + +#[derive(Clone)] +pub struct TimingPoint { + pub time: Time, + pub beat_length: f32, + pub meter: u8, + pub sample_set: SampleSet, + pub sample_index: SampleIndex, + pub volume: u8, + pub uninherited: bool, + pub effects: TimingPointEffects, +} + +impl fmt::Display for TimingPoint { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{},{},{},{},{},{},{},{}", + self.time, + self.beat_length, + self.meter, + ToPrimitive::to_u8(&self.sample_set).unwrap(), + self.sample_index, + self.volume, + self.uninherited as u8, + self.effects + ) + } +} + +#[derive(Clone)] +pub struct Colours(pub Vec); + +impl fmt::Display for Colours { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [Colours]\n\ + {}\n\ + ", + join_display_values(self.0.clone(), "\n") + ) + } +} + +#[derive(Clone, Debug)] +pub enum ColourScope { + Combo(u16), + SliderTrackOverride, + SliderBorder, +} + +impl fmt::Display for ColourScope { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ColourScope::Combo(i) => write!(f, "Combo{}", i), + _ => write!(f, "{:?}", self), + } + } +} + +#[derive(Clone)] +pub struct Colour { + pub scope: ColourScope, + pub colour: [u8; 3], +} + +impl fmt::Display for Colour { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{} : {}", + self.scope, + join_display_values(self.colour.to_vec(), ",") + ) + } +} + +#[derive(Clone)] +pub struct HitSound { + pub normal: bool, + pub whistle: bool, + pub finish: bool, + pub clap: bool, +} + +impl fmt::Display for HitSound { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + bitflags([ + self.normal, + self.whistle, + self.finish, + self.clap, + false, + false, + false, + false + ]) + ) + } +} + +#[derive(Clone)] +pub struct HitSample { + pub normal_set: SampleIndex, + pub addition_set: SampleIndex, + pub index: SampleIndex, + pub volume: u8, + pub filename: String, +} + +impl fmt::Display for HitSample { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}:{}:{}:{}:{}", + self.normal_set, self.addition_set, self.index, self.volume, self.filename + ) + } +} + +// Three bit integer +pub type U3 = u8; + +#[derive(Clone)] +pub enum HitObject { + HitCircle { + x: OsuPixel, + y: OsuPixel, + time: Time, + hit_sound: HitSound, + new_combo: bool, + skip_combo_colours: U3, + hit_sample: HitSample, + }, + Slider { + x: OsuPixel, + y: OsuPixel, + time: Time, + hit_sound: HitSound, + new_combo: bool, + skip_combo_colours: U3, + curve_type: char, + curve_points: Vec<(DecimalOsuPixel, DecimalOsuPixel)>, + slides: u8, + length: DecimalOsuPixel, + edge_sounds: Vec, + edge_sets: Vec<(SampleSet, SampleSet)>, + hit_sample: HitSample, + }, + Spinner { + time: Time, + hit_sound: HitSound, + new_combo: bool, + skip_combo_colours: U3, + end_time: Time, + hit_sample: HitSample, + }, + Hold { + column: u8, + columns: u8, + time: Time, + hit_sound: HitSound, + new_combo: bool, + skip_combo_colours: U3, + end_time: Time, + hit_sample: HitSample, + }, +} + +impl fmt::Display for HitObject { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + HitObject::HitCircle { + x, + y, + time, + hit_sound, + new_combo, + skip_combo_colours, + hit_sample, + } => write!( + f, + "{},{},{},{},{},{}", + x, + y, + time, + assemble_hit_object_type(0, *new_combo, *skip_combo_colours), + hit_sound, + hit_sample + ), + HitObject::Slider { + x, + y, + time, + hit_sound, + new_combo, + skip_combo_colours, + curve_type, + curve_points, + slides, + length, + edge_sounds, + edge_sets, + hit_sample, + } => write!( + f, + "{},{},{},{},{},{}|{},{},{},{},{},{}", + x, + y, + time, + assemble_hit_object_type(1, *new_combo, *skip_combo_colours), + hit_sound, + curve_type, + curve_points + .iter() + .map(|point| format!("{}:{}", point.0, point.1)) + .collect::>() + .join("|"), + slides, + length, + join_display_values(edge_sounds.clone(), "|"), + edge_sets + .iter() + .map(|set| format!( + "{}:{}", + ToPrimitive::to_u16(&set.0).unwrap(), + ToPrimitive::to_u16(&set.1).unwrap() + )) + .collect::>() + .join("|"), + hit_sample + ), + HitObject::Spinner { + time, + hit_sound, + new_combo, + skip_combo_colours, + end_time, + hit_sample, + } => write!( + f, + "256,192,{},{},{},{},{}", + time, + assemble_hit_object_type(3, *new_combo, *skip_combo_colours), + hit_sound, + end_time, + hit_sample + ), + HitObject::Hold { + column, + columns, + time, + hit_sound, + new_combo, + skip_combo_colours, + end_time, + hit_sample, + } => write!( + f, + "{},192,{},{},{},{}:{}", + column_to_x(*column, *columns), + time, + assemble_hit_object_type(7, *new_combo, *skip_combo_colours), + hit_sound, + end_time, + hit_sample + ), + } + } +} + +#[derive(Clone)] +pub struct HitObjects(pub Vec); + +impl fmt::Display for HitObjects { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "\ + [HitObjects]\n\ + {}\n\ + ", + join_display_values(self.0.clone(), "\n") + ) + } +} + +pub struct Beatmap { + pub version: u8, + pub general: General, + pub editor: Editor, + pub metadata: Metadata, + pub difficulty: Difficulty, + pub events: Events, + pub timing_points: TimingPoints, + pub colours: Colours, + pub hit_objects: HitObjects, +} + +impl fmt::Display for Beatmap { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "osu file format v{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n", + self.version, + self.general.clone(), + self.editor.clone(), + self.metadata.clone(), + self.difficulty.clone(), + self.events.clone(), + self.timing_points.clone(), + self.colours.clone(), + self.hit_objects.clone() + ) + } +} diff --git a/src/osu/osz.rs b/src/osu/osz.rs new file mode 100644 index 0000000..e1bdf5f --- /dev/null +++ b/src/osu/osz.rs @@ -0,0 +1,43 @@ +use std::fs::File; +use std::io::{Result, Write}; +use std::path::PathBuf; + +use zip::write::{FileOptions, ZipWriter}; + +use crate::osu::beatmap; + +pub struct Archive<'a> { + pub beatmaps: Vec, + pub assets: Vec<(&'a str, &'a [u8])>, +} + +impl Archive<'_> { + pub fn write(&self, filename: &PathBuf) -> Result<()> { + let file = File::create(filename)?; + let mut zip = ZipWriter::new(file); + + for beatmap in &self.beatmaps { + let filename = format!( + "{} - {} ({}) [{}].osu", + beatmap.metadata.artist, + beatmap.metadata.title, + beatmap.metadata.creator, + beatmap.metadata.version + ); + let options = FileOptions::default(); + zip.start_file(filename, options)?; + zip.write_all(format!("{}", beatmap).as_bytes())?; + } + + for asset in &self.assets { + // Assets mostly are already compressed (e.g. JPEG, MP3) + let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file(asset.0, options)?; + zip.write_all(asset.1)?; + } + + zip.finish()?; + + Ok(()) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..4597ab1 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,39 @@ +use anyhow::{anyhow, Result}; +use log::debug; + +pub fn get_nth_bit(byte: u8, n: u8) -> bool { + ((byte & (0b1 << n)) >> n) != 0 +} + +pub fn byte_to_bitarray(byte: u8) -> [bool; 8] { + let mut bitarray = [false; 8]; + for (i, bit) in bitarray.iter_mut().enumerate() { + *bit = get_nth_bit(byte, i as u8); + } + bitarray +} + +pub fn offset_length_to_start_end(offset: usize, length: usize) -> (usize, usize) { + (offset, offset + length) +} + +// This probably isn’t the right way to do this, but after countless attempts to implement +// error conversion (IResult to anyhow::Result) it was the only thing I could come up with. +pub fn exec_nom_parser<'a, F, R>(func: F, input: &'a [u8]) -> Result +where + F: Fn(&'a [u8]) -> nom::IResult<&[u8], R>, +{ + match func(input) { + Ok((unprocessed, result)) => { + if !unprocessed.is_empty() { + debug!( + "Parser returned {} bytes of unprocessed input: {:?}", + unprocessed.len(), + unprocessed + ); + } + Ok(result) + } + Err(error) => Err(anyhow!("Nom returned error: {}", error)), + } +} diff --git a/src/xact3.rs b/src/xact3.rs new file mode 100644 index 0000000..e697876 --- /dev/null +++ b/src/xact3.rs @@ -0,0 +1,2 @@ +mod adpcm; +pub mod xwb; diff --git a/src/xact3/adpcm.rs b/src/xact3/adpcm.rs new file mode 100644 index 0000000..bd20b13 --- /dev/null +++ b/src/xact3/adpcm.rs @@ -0,0 +1,118 @@ +use std::io::{Cursor, Write}; + +use anyhow::Result; +use byteorder::{LittleEndian, WriteBytesExt}; +use log::{debug, trace}; + +#[rustfmt::skip] +const COEFFS: &[CoefSet] = &[ + (256, 0), + (512, -256), + ( 0, 0), + (192, 64), + (240, 0), + (460, -208), + (392, -232), +]; + +trait WaveChunk { + fn to_chunk(&self) -> Vec; +} + +type CoefSet = (i16, i16); + +pub struct WaveFormat { + // w_format_tag = 2 + pub n_channels: u16, + pub n_samples_per_sec: u32, + pub n_avg_bytes_per_sec: u32, + pub n_block_align: u16, + // w_bits_per_sample = 4 + // cb_size = 32 + pub n_samples_per_block: u16, + // w_num_coeff = 7 + // a_coeff = COEFFS +} + +impl WaveChunk for WaveFormat { + fn to_chunk(&self) -> Vec { + let mut buf = Cursor::new(Vec::new()); + write!(buf, "fmt ").unwrap(); + buf.write_u32::(2 + 2 + 4 + 4 + 2 + 2 + 2 + 2 + 2 + 4 * COEFFS.len() as u32) + .unwrap(); + buf.write_u16::(2).unwrap(); // WAVE_FORMAT_ADPCM + buf.write_u16::(self.n_channels).unwrap(); + buf.write_u32::(self.n_samples_per_sec) + .unwrap(); + buf.write_u32::(self.n_avg_bytes_per_sec) + .unwrap(); + buf.write_u16::(self.n_block_align).unwrap(); + buf.write_u16::(4).unwrap(); // wBitsPerSample + buf.write_u16::(32).unwrap(); // cbSize + buf.write_u16::(self.n_samples_per_block) + .unwrap(); + buf.write_u16::(7).unwrap(); // wNumCoeff + for coef_set in COEFFS { + buf.write_i16::(coef_set.0).unwrap(); + buf.write_i16::(coef_set.1).unwrap(); + } + buf.into_inner() + } +} + +struct WaveFact { + length_samples: u32, +} + +impl WaveChunk for WaveFact { + fn to_chunk(&self) -> Vec { + let mut buf = Cursor::new(Vec::new()); + write!(buf, "fact").unwrap(); + buf.write_u32::(4).unwrap(); + buf.write_u32::(self.length_samples).unwrap(); + buf.into_inner() + } +} + +struct RIFFHeader { + file_size: u32, +} + +impl WaveChunk for RIFFHeader { + fn to_chunk(&self) -> Vec { + let mut buf = Cursor::new(Vec::new()); + write!(buf, "RIFF").unwrap(); + buf.write_u32::(self.file_size).unwrap(); + write!(buf, "WAVE").unwrap(); + buf.into_inner() + } +} + +pub fn build_wav(format: WaveFormat, data: &[u8]) -> Result> { + debug!("Building file"); + let riff_header = RIFFHeader { + file_size: 82 + data.len() as u32, + }; + + let fact = WaveFact { + length_samples: ((data.len() as u32 / format.n_block_align as u32) + * ((format.n_block_align - (7 * format.n_channels)) * 8) as u32 + / 4) + / format.n_channels as u32, + }; + + let mut buf = Cursor::new(Vec::new()); + + trace!("Building RIFF header"); + buf.write_all(&riff_header.to_chunk())?; + trace!("Building fmt chunk"); + buf.write_all(&format.to_chunk())?; + trace!("Building fact chunk"); + buf.write_all(&fact.to_chunk())?; + + write!(buf, "data")?; + buf.write_u32::(data.len() as u32).unwrap(); + buf.write_all(data)?; + + Ok(buf.into_inner()) +} diff --git a/src/xact3/xwb.rs b/src/xact3/xwb.rs new file mode 100644 index 0000000..3181a41 --- /dev/null +++ b/src/xact3/xwb.rs @@ -0,0 +1,235 @@ +use std::collections::HashMap; +use std::convert::TryInto; + +use anyhow::{anyhow, Result}; +use log::{debug, info, warn}; +use nom::bytes::complete::tag; +use nom::error::ParseError; +use nom::multi::count; +use nom::number::complete::{le_i32, le_u32}; +use nom::{take_str, IResult}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; + +use crate::utils; +use crate::utils::exec_nom_parser; +use crate::xact3::adpcm; + +#[derive(Clone, FromPrimitive, Debug, PartialEq)] +enum FormatTag { + PCM = 0, + XMA = 1, + ADPCM = 2, + WMA = 3, +} + +#[derive(Clone, Debug)] +struct Format { + tag: FormatTag, + channels: u16, + sample_rate: u32, + alignment: u8, +} + +impl From for Format { + fn from(format: u32) -> Self { + Self { + tag: FormatTag::from_u32(format & ((1 << 2) - 1)).unwrap(), + channels: ((format >> 2) & ((1 << 3) - 1)) as u16, + sample_rate: (format >> 5) & ((1 << 18) - 1), + alignment: ((format >> 23) & ((1 << 8) - 1)) as u8, + } + } +} + +impl TryInto for Format { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + if self.tag != FormatTag::ADPCM { + return Err(anyhow!("Format is not ADPCM")); + } + + let n_block_align = (self.alignment as u16 + 22) * self.channels; + let n_samples_per_block = + (((n_block_align - (7 * self.channels)) * 8) / (4 * self.channels)) + 2; + let n_avg_bytes_per_sec = + (self.sample_rate / n_samples_per_block as u32) * n_block_align as u32; + + Ok(adpcm::WaveFormat { + n_channels: self.channels, + n_samples_per_sec: self.sample_rate, + n_avg_bytes_per_sec, + n_block_align, + n_samples_per_block, + }) + } +} + +#[derive(Debug)] +struct SegmentPosition { + start: usize, + end: usize, +} + +impl SegmentPosition { + fn get_from<'a>(&self, data: &'a [u8]) -> &'a [u8] { + &data[self.start..self.end] + } + + fn parse<'a, E: ParseError<&'a [u8]>>(input: &'a [u8]) -> IResult<&[u8], Self, E> { + let (input, offset) = le_u32(input)?; + let (input, length) = le_u32(input)?; + + let (start, end) = utils::offset_length_to_start_end(offset as usize, length as usize); + Ok((input, Self { start, end })) + } +} + +struct Header { + segment_positions: Vec, +} + +impl Header { + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, _magic) = tag("WBND")(input)?; + let (input, version) = le_u32(input)?; + debug!("Recognised file (version {})", version); + if version != 43 { + warn!("The provided file has an unsupported version ({})", version); + } + let (input, _header_version) = le_u32(input)?; + let (_input, segment_positions) = count(SegmentPosition::parse, 5)(input)?; + Ok(( + // difference between first segment and parsed bytes of header + &input[8 * 5 + 12..segment_positions[0].start], + Self { segment_positions }, + )) + } +} + +#[derive(Debug)] +struct Info { + entry_count: usize, + name: String, +} + +impl Info { + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, _flags) = le_u32(input)?; + let (input, entry_count) = le_u32(input)?; + let (input, name) = take_str64(input)?; + let (input, _entry_meta_data_element_size) = le_u32(input)?; + let (input, _entry_name_element_size) = le_u32(input)?; + let (input, _alignment) = le_u32(input)?; + let (input, _compact_format) = le_i32(input)?; + let (input, _build_time) = le_u32(input)?; + + Ok(( + input, + Self { + entry_count: entry_count as usize, + name, + }, + )) + } +} + +#[derive(Debug)] +struct Entry { + name: String, + format: Format, + data_offset: usize, + data_length: usize, +} + +impl Entry { + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, _flags_and_duration) = le_u32(input)?; + let (input, format) = le_u32(input)?; + let (input, data_offset) = le_u32(input)?; + let (input, data_length) = le_u32(input)?; + let (input, _loop_start) = le_u32(input)?; + let (input, _loop_length) = le_u32(input)?; + + Ok(( + input, + Self { + name: "".to_string(), + format: format.into(), + data_offset: data_offset as usize, + data_length: data_length as usize, + }, + )) + } +} + +fn take_str(input: &[u8], len: usize) -> IResult<&[u8], String> { + let (input, parsed) = take_str!(input, len)?; + Ok((input, parsed.replace("\0", ""))) +} + +fn take_str64(input: &[u8]) -> IResult<&[u8], String> { + take_str(input, 64) +} + +#[derive(Debug, Clone)] +pub struct WaveBank<'a> { + pub name: String, + pub sounds: HashMap>, +} + +impl WaveBank<'_> { + pub fn parse(data: &'_ [u8]) -> Result { + debug!("Parsing header"); + let header = exec_nom_parser(Header::parse, data)?; + + debug!("Getting segments from file"); + let segments: Vec<&'_ [u8]> = header + .segment_positions + .iter() + .map(|x| x.get_from(data)) + .collect(); + + debug!("Parsing info"); + let info = exec_nom_parser(Info::parse, segments[0])?; + debug!("Parsing entries"); + let entries = exec_nom_parser(count(Entry::parse, info.entry_count as usize), segments[1])?; + debug!("Parsing entry names"); + let entry_names = + exec_nom_parser(count(take_str64, info.entry_count as usize), segments[3])?; + + let mut wave_bank = WaveBank { + name: info.name, + sounds: HashMap::new(), + }; + + for (entry, name) in entries.iter().zip(entry_names.iter()) { + let (start, end) = + utils::offset_length_to_start_end(entry.data_offset, entry.data_length); + wave_bank.sounds.insert( + name.replace("\0", "").to_string(), + Sound { + format: entry.format.clone(), + data: &segments[4][start..end], + }, + ); + } + + info!("Parsed WaveBank with {} sounds", wave_bank.sounds.len()); + + Ok(wave_bank) + } +} + +#[derive(Clone, Debug)] +pub struct Sound<'a> { + format: Format, + data: &'a [u8], +} + +impl Sound<'_> { + pub fn to_wav(&self) -> Result> { + adpcm::build_wav(self.format.clone().try_into()?, self.data) + } +}