From 4e16a6c327f7214d308b0cd108cc662103f32715 Mon Sep 17 00:00:00 2001 From: DexalGT Date: Tue, 4 Aug 2026 00:42:13 +0300 Subject: [PATCH 1/5] feat(audio): move BnkExtract onto ritoshark and drop the Wwise toolchain Quartz downloaded WwiseConsole.exe and vgmstream-cli.exe from tarngaina/LtMAO into %APPDATA%/RitoShark/AudioTools and shelled out to them. ritoshark f646d96 encodes Wwise Vorbis in-process, which was the only thing that genuinely needed an external toolchain, so both are gone along with the install flow, the progress modal and the needs-tools gating. Any copy an older version left behind is deleted on startup. Decoding was already native but unreachable: audio_decode_to_wav only accepted the result if it came back as wav, and every League wem is Vorbis, so the check never passed and each decode spawned vgmstream anyway. User mp3/flac/ogg/m4a now goes through symphonia, which is the app's side of the boundary rs_audio deliberately draws at PCM samples. Containers moved to rs_audio too: bnk_save_bank rebuilt the bank from scratch on every save, writing a 20-byte BKHD at version 134 with bank id 0 and dropping HIRC and every unmodelled section, and it sorted entries by id so order was lost as well. It now applies the edited entry set to the container the tree was loaded from, so the header and hierarchy survive. Gain no longer round-trips through two processes; it decodes to pcm, scales, and re-encodes into the codec the sound already used. --- src-tauri/Cargo.lock | 248 ++- src-tauri/crates/quartz-lib/Cargo.toml | 11 +- .../resources/packed_codebooks_aoTuV_603.bin | Bin 74387 -> 0 bytes src-tauri/crates/quartz-lib/src/audio/bank.rs | 307 ++++ src-tauri/crates/quartz-lib/src/audio/bnk.rs | 345 ----- .../crates/quartz-lib/src/audio/decode.rs | 162 ++ src-tauri/crates/quartz-lib/src/audio/mod.rs | 5 +- src-tauri/crates/quartz-lib/src/audio/tree.rs | 12 +- src-tauri/crates/quartz-lib/src/audio/wem.rs | 1376 ----------------- src-tauri/crates/quartz-lib/src/audio/wpk.rs | 309 ---- src-tauri/src/commands/audio.rs | 894 ++--------- src-tauri/src/main.rs | 6 +- src/pages/BnkExtract.tsx | 67 +- .../bnkextract/components/BnkContextMenu.tsx | 10 +- .../bnkextract/components/BnkInstallModal.tsx | 54 - src/pages/bnkextract/utils/backend.ts | 47 +- 16 files changed, 847 insertions(+), 3006 deletions(-) delete mode 100644 src-tauri/crates/quartz-lib/resources/packed_codebooks_aoTuV_603.bin create mode 100644 src-tauri/crates/quartz-lib/src/audio/bank.rs delete mode 100644 src-tauri/crates/quartz-lib/src/audio/bnk.rs create mode 100644 src-tauri/crates/quartz-lib/src/audio/decode.rs delete mode 100644 src-tauri/crates/quartz-lib/src/audio/wem.rs delete mode 100644 src-tauri/crates/quartz-lib/src/audio/wpk.rs delete mode 100644 src/pages/bnkextract/components/BnkInstallModal.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2a91ee38..20ff3c2a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,16 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "aotuv_lancer_vorbis_sys" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc4fd1a61860d2f1198b60bedd30910eaffa978f1ee6214dfb24ac70d589225" +dependencies = [ + "cc", + "ogg_next_sys", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -56,6 +66,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1070,6 +1086,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.4.1" @@ -2198,6 +2220,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2708,6 +2741,24 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "ogg_next_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2d7a48e247c2bb07e633aefb65a38648ea58c7eedd4e4408a5861721ab049b" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3184,6 +3235,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "symphonia", "tempfile", "thiserror 1.0.69", "tokio", @@ -3543,7 +3595,7 @@ dependencies = [ [[package]] name = "ritoshark" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_anim", "rs_audio", @@ -3565,7 +3617,7 @@ dependencies = [ [[package]] name = "rs_anim" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_hash", "rs_io", @@ -3576,16 +3628,19 @@ dependencies = [ [[package]] name = "rs_audio" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ + "lewton", + "ogg", "rs_io", "thiserror 2.0.18", + "vorbis_rs", ] [[package]] name = "rs_bin" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "indexmap 2.14.0", "rs_hash", @@ -3597,12 +3652,12 @@ dependencies = [ [[package]] name = "rs_file" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" [[package]] name = "rs_hash" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "thiserror 2.0.18", "xxhash-rust", @@ -3611,7 +3666,7 @@ dependencies = [ [[package]] name = "rs_io" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "memmap2", "rs_math", @@ -3621,7 +3676,7 @@ dependencies = [ [[package]] name = "rs_luabin" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_io", "thiserror 2.0.18", @@ -3630,7 +3685,7 @@ dependencies = [ [[package]] name = "rs_mapgeo" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_hash", "rs_io", @@ -3641,7 +3696,7 @@ dependencies = [ [[package]] name = "rs_math" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "glam", ] @@ -3649,7 +3704,7 @@ dependencies = [ [[package]] name = "rs_mesh" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_io", "rs_math", @@ -3659,7 +3714,7 @@ dependencies = [ [[package]] name = "rs_rman" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_io", "thiserror 2.0.18", @@ -3669,7 +3724,7 @@ dependencies = [ [[package]] name = "rs_rst" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "indexmap 2.14.0", "rs_hash", @@ -3680,7 +3735,7 @@ dependencies = [ [[package]] name = "rs_tex" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "ddsfile", "image", @@ -3695,7 +3750,7 @@ dependencies = [ [[package]] name = "rs_troybin" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_hash", "rs_io", @@ -3705,7 +3760,7 @@ dependencies = [ [[package]] name = "rs_wad" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "flate2", "rs_hash", @@ -4350,6 +4405,153 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -5473,6 +5675,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vorbis_rs" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c5da94d280f7a27e8c937e9b73df2da3e23a2583f48471fd8fb4c72f9c1933" +dependencies = [ + "aotuv_lancer_vorbis_sys", + "errno", + "getrandom 0.4.2", + "ogg_next_sys", + "thiserror 2.0.18", + "tinyvec", +] + [[package]] name = "vswhom" version = "0.1.0" diff --git a/src-tauri/crates/quartz-lib/Cargo.toml b/src-tauri/crates/quartz-lib/Cargo.toml index c1de839d..e86621dc 100644 --- a/src-tauri/crates/quartz-lib/Cargo.toml +++ b/src-tauri/crates/quartz-lib/Cargo.toml @@ -42,6 +42,11 @@ zstd = "0.12" # Binary parsing for BNK/WPK/WEM audio byteorder = "1.5" +# Decoding user-supplied audio (mp3/flac/ogg/wav/m4a) down to PCM samples, which +# is where rs_audio takes over. Reading user audio files is explicitly out of +# scope for that crate, so it is the app's side of the boundary. +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aac", "isomp4"] } + # Data indexmap = { version = "2.1", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } @@ -52,10 +57,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # Logging (macros only) tracing = "0.1" -# RitoShark crates — BIN/format handling + TEX decode. Pinned to a rev branched -# off d6af5ac (the rev Flint uses) that adds Wad::from_reader_toc + seek-per-chunk -# reads for lightweight WAD indexing/extraction (branch: wad-toc-seek). -ritoshark = { git = "https://github.com/RitoShark/RitoShark-Crates", rev = "2ed5554828764cf0ce9f3f426ed6352fea39578a", features = ["tex", "anim", "mesh"] } +# RitoShark crates — BIN/format handling, TEX decode, audio (bnk/wpk/wem). +ritoshark = { git = "https://github.com/RitoShark/RitoShark-Crates", rev = "24fe6b3", features = ["tex", "anim", "mesh", "audio"] } [dev-dependencies] # Scratch dirs for the live-install extraction tests (auto-removed on drop). diff --git a/src-tauri/crates/quartz-lib/resources/packed_codebooks_aoTuV_603.bin b/src-tauri/crates/quartz-lib/resources/packed_codebooks_aoTuV_603.bin deleted file mode 100644 index a405d061b50cd793e17ad99b1f9f809ee372812a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74387 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 diff --git a/src-tauri/crates/quartz-lib/src/audio/bank.rs b/src-tauri/crates/quartz-lib/src/audio/bank.rs new file mode 100644 index 00000000..25b83a97 --- /dev/null +++ b/src-tauri/crates/quartz-lib/src/audio/bank.rs @@ -0,0 +1,307 @@ +/* BNK/WPK containers and WEM audio, backed by ritoshark::audio. + +Edits modify the parsed container rather than rebuilding it, so a bank keeps its header revision, +its bank id, its object hierarchy and every section this app does not model, and a package keeps +its entry names, its slot order and its dead slots. Rebuilding on each save is what produces a +file the engine cannot load even though the audio inside it is fine. + +Replacement audio is encoded to Wwise Vorbis — the one codec the game ships — which is what +removed the need for an external Wwise toolchain. */ + +use std::collections::HashSet; + +use ritoshark::audio::{ + AudioFormat, Bnk, PcmAudio, Wem, WemCodec, Wpk, encode_vorbis, encode_vorbis_like, +}; +use ritoshark::prelude::{Parse, Serialize}; +use serde::{Deserialize, Serialize as SerdeSerialize}; + +/// Vorbis convention: -0.2 worst, 1.0 best. 0.5 is the crate's documented default. +const VORBIS_QUALITY: f32 = 0.5; + +#[derive(Debug, Clone)] +pub struct AudioEntry { + pub id: u32, + pub data: Vec, +} + +/// A decoded WEM as playable bytes. +#[derive(Debug, Clone, SerdeSerialize, Deserialize)] +pub struct DecodedAudio { + pub data: Vec, + /// `"ogg"` or `"wav"`. + pub format: String, + pub sample_rate: Option, +} + +/// Either Wwise container, parsed. Both hold WEM payloads addressed by a numeric id. +pub enum Bank { + Bnk(Bnk), + Wpk(Wpk), +} + +impl Bank { + pub fn parse(data: &[u8]) -> Result { + match data.get(..4) { + Some(b"BKHD") => Bnk::from_bytes(data) + .map(Self::Bnk) + .map_err(|e| format!("Failed to parse BNK: {e}")), + Some(b"r3d2") => Wpk::from_bytes(data) + .map(Self::Wpk) + .map_err(|e| format!("Failed to parse WPK: {e}")), + Some(magic) => Err(format!( + "Unknown audio format (magic: {:02X}{:02X}{:02X}{:02X})", + magic[0], magic[1], magic[2], magic[3] + )), + None => Err("Audio file too small".into()), + } + } + + /* A WPK entry whose name is not ".wem" has no id to address it by, so it is left out + rather than listed under a placeholder nothing could resolve. */ + pub fn entries(&self) -> Vec { + match self { + Self::Bnk(bnk) => bnk + .wems() + .into_iter() + .map(|(id, data)| AudioEntry { + id, + data: data.to_vec(), + }) + .collect(), + Self::Wpk(wpk) => wpk + .wems() + .into_iter() + .filter_map(|(id, _, data)| { + Some(AudioEntry { + id: id?, + data: data.to_vec(), + }) + }) + .collect(), + } + } + + pub fn ids(&self) -> Vec { + self.entries().into_iter().map(|e| e.id).collect() + } + + pub fn entry(&self, id: u32) -> Option<&[u8]> { + match self { + Self::Bnk(bnk) => bnk.wem(id), + Self::Wpk(wpk) => wpk.wem(id), + } + } + + pub fn insert(&mut self, id: u32, data: Vec) -> Result<(), String> { + match self { + Self::Bnk(bnk) => bnk.insert_wem(id, data), + Self::Wpk(wpk) => wpk.insert_wem(id, data), + } + .map_err(|e| format!("Failed to write entry {id}: {e}")) + } + + pub fn remove(&mut self, id: u32) -> Result<(), String> { + match self { + Self::Bnk(bnk) => bnk.remove_wem(id), + Self::Wpk(wpk) => wpk.remove_wem(id), + } + .map_err(|e| format!("Failed to remove entry {id}: {e}")) + } + + pub fn to_bytes(&self) -> Result, String> { + match self { + Self::Bnk(bnk) => bnk.to_bytes(), + Self::Wpk(wpk) => wpk.to_bytes(), + } + .map_err(|e| format!("Failed to serialize audio bank: {e}")) + } +} + +/// Every embedded WEM in a BNK or WPK buffer, in container order. +pub fn all_entries(data: &[u8]) -> Result, String> { + Ok(Bank::parse(data)?.entries()) +} + +/** Applies an edited entry set to the container it came from. + +Starting from the original is what keeps the header, the hierarchy and every unmodelled section +intact. Ids the caller no longer lists are removed, so a bank saved after deleting a sound really +loses it. */ +pub fn save_with_entries(original: &[u8], entries: &[AudioEntry]) -> Result, String> { + let mut bank = Bank::parse(original)?; + + let keep: HashSet = entries.iter().map(|e| e.id).collect(); + for id in bank.ids() { + if !keep.contains(&id) { + bank.remove(id)?; + } + } + for entry in entries { + bank.insert(entry.id, entry.data.clone())?; + } + + bank.to_bytes() +} + +/// Decodes a WEM to a playable stream — Ogg for Wwise Vorbis, WAV for PCM. +pub fn decode_wem(data: &[u8]) -> Result { + let decoded = Wem::new(data) + .and_then(|wem| wem.decode()) + .map_err(|e| format!("Failed to decode WEM: {e}"))?; + + Ok(DecodedAudio { + data: decoded.data, + format: match decoded.format { + AudioFormat::Ogg => "ogg", + AudioFormat::Wav => "wav", + } + .into(), + sample_rate: Some(decoded.sample_rate), + }) +} + +/// Decodes a WEM all the way to interleaved 16-bit samples. +pub fn wem_to_pcm(data: &[u8]) -> Result { + Wem::new(data) + .and_then(|wem| wem.to_pcm()) + .map_err(|e| format!("Failed to decode WEM: {e}")) +} + +/// Wraps interleaved 16-bit samples in a RIFF/WAVE header. +pub fn pcm_to_wav(audio: &PcmAudio) -> Vec { + let data_len = audio.samples.len() * 2; + let byte_rate = audio.sample_rate * u32::from(audio.channels) * 2; + let block_align = audio.channels * 2; + + let mut out = Vec::with_capacity(44 + data_len); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_len as u32).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&audio.channels.to_le_bytes()); + out.extend_from_slice(&audio.sample_rate.to_le_bytes()); + out.extend_from_slice(&byte_rate.to_le_bytes()); + out.extend_from_slice(&block_align.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data_len as u32).to_le_bytes()); + for sample in &audio.samples { + out.extend_from_slice(&sample.to_le_bytes()); + } + out +} + +/// Encodes samples into a Wwise Vorbis WEM, cloning `reference`'s header when there is one. +pub fn encode_wem(audio: &PcmAudio, reference: Option<&[u8]>) -> Result, String> { + /* A template only works if the reference really is Wwise Vorbis; League ships nothing else, + but a bank holding PCM would otherwise fail outright. */ + let template = reference + .filter(|bytes| Wem::new(bytes).is_ok_and(|wem| wem.format().codec == WemCodec::Vorbis)); + + match template { + Some(bytes) => encode_vorbis_like(bytes, audio, VORBIS_QUALITY), + None => encode_vorbis(audio, VORBIS_QUALITY), + } + .map_err(|e| format!("Failed to encode WEM: {e}")) +} + +/** Turns whatever the user supplied into an embeddable WEM. + +Anything that already parses as one is embedded verbatim, which keeps a WEM lifted out of another +bank bit-identical. Everything else is decoded to samples and encoded. */ +pub fn to_wem(data: &[u8], reference: Option<&[u8]>) -> Result, String> { + if Wem::new(data).is_ok() { + return Ok(data.to_vec()); + } + let pcm = super::decode::decode_any(data)?; + encode_wem(&pcm, reference) +} + +/// Scales a WEM by `gain_db`, re-encoding into the codec it already used. +pub fn amplify_wem(data: &[u8], gain_db: f32) -> Result, String> { + let mut pcm = wem_to_pcm(data)?; + let factor = 10f32.powf(gain_db / 20.0); + for sample in &mut pcm.samples { + *sample = (f32::from(*sample) * factor).clamp(f32::from(i16::MIN), f32::from(i16::MAX)) + as i16; + } + encode_wem(&pcm, Some(data)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ritoshark::audio::{BnkSection, silence}; + + fn bank_with_one_wem() -> Vec { + let mut bnk = Bnk::new(); + bnk.sections.push(BnkSection { + tag: *b"BKHD", + data: vec![0x91, 0, 0, 0, 0xEF, 0xBE, 0xAD, 0xDE], + }); + bnk.sections.push(BnkSection { + tag: *b"HIRC", + data: vec![0, 0, 0, 0], + }); + bnk.insert_wem(100, silence(44100, 1, 4096).unwrap()).unwrap(); + bnk.to_bytes().unwrap() + } + + #[test] + fn saving_an_edited_entry_set_keeps_the_header_and_hierarchy() { + let original = bank_with_one_wem(); + let entries = vec![AudioEntry { + id: 100, + data: silence(44100, 1, 2048).unwrap(), + }]; + + let saved = save_with_entries(&original, &entries).unwrap(); + let Bank::Bnk(bnk) = Bank::parse(&saved).unwrap() else { + panic!("still a bnk"); + }; + + assert_eq!(bnk.version(), Some(0x91), "header revision must survive"); + assert_eq!(bnk.bank_id(), Some(0xDEADBEEF), "bank id must survive"); + assert!( + bnk.sections.iter().any(|s| s.tag == *b"HIRC"), + "the object hierarchy must survive a save" + ); + } + + #[test] + fn saving_drops_entries_the_caller_no_longer_lists() { + let original = bank_with_one_wem(); + let saved = save_with_entries(&original, &[]).unwrap(); + assert!(Bank::parse(&saved).unwrap().entries().is_empty()); + } + + #[test] + fn an_existing_wem_is_embedded_verbatim() { + let wem = silence(32000, 2, 64).unwrap(); + assert_eq!(to_wem(&wem, None).unwrap(), wem); + } + + #[test] + fn amplifying_keeps_the_stream_playable_and_vorbis() { + let original = silence(44100, 1, 4096).unwrap(); + let louder = amplify_wem(&original, 6.0).unwrap(); + + let wem = Wem::new(&louder).expect("amplified audio must still be a wem"); + assert_eq!(wem.format().codec, WemCodec::Vorbis); + assert_eq!(wem.format().sample_rate, 44100); + } + + #[test] + fn pcm_round_trips_through_the_wav_wrapper() { + let pcm = PcmAudio::new(22050, 2, vec![1, -1, 300, -300]); + let wav = pcm_to_wav(&pcm); + + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 22050); + assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 2); + assert_eq!(wav.len(), 44 + 8); + } +} diff --git a/src-tauri/crates/quartz-lib/src/audio/bnk.rs b/src-tauri/crates/quartz-lib/src/audio/bnk.rs deleted file mode 100644 index 27d09c1c..00000000 --- a/src-tauri/crates/quartz-lib/src/audio/bnk.rs +++ /dev/null @@ -1,345 +0,0 @@ -use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize}; -use std::io::{Cursor, Read, Write}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioEntryInfo { - pub id: u32, - pub size: u32, -} - -#[derive(Debug, Clone)] -pub struct AudioEntry { - pub id: u32, - pub data: Vec, -} - -#[derive(Debug)] -pub struct BnkFile { - pub version: u32, - pub bank_id: u32, - /// DIDX entries (file_id, offset_in_data, length) - entries: Vec<(u32, u32, u32)>, - /// Absolute offset of the DATA section payload in the original buffer - data_section_offset: usize, - pub hirc_bytes: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BnkInfo { - pub format: String, - pub version: u32, - pub bank_id: u32, - pub entry_count: usize, - pub entries: Vec, - pub has_hirc: bool, -} - -impl BnkFile { - pub fn parse(data: &[u8]) -> Result { - let mut cursor = Cursor::new(data); - let len = data.len() as u64; - - let mut magic = [0u8; 4]; - cursor - .read_exact(&mut magic) - .map_err(|e| format!("Failed to read BNK magic: {e}"))?; - if &magic != b"BKHD" { - return Err("Not a valid BNK file — missing BKHD header".into()); - } - let bkhd_len = cursor - .read_u32::() - .map_err(|e| format!("Failed to read BKHD length: {e}"))?; - let version = cursor - .read_u32::() - .map_err(|e| format!("Failed to read BNK version: {e}"))?; - let bank_id = cursor - .read_u32::() - .map_err(|e| format!("Failed to read bank ID: {e}"))?; - - let bkhd_remaining = bkhd_len.saturating_sub(8) as u64; - cursor.set_position(cursor.position() + bkhd_remaining); - - let mut entries: Vec<(u32, u32, u32)> = Vec::new(); - let mut data_section_offset: usize = 0; - let mut hirc_bytes: Option> = None; - - while cursor.position() + 8 <= len { - let mut section_magic = [0u8; 4]; - if cursor.read_exact(&mut section_magic).is_err() { - break; - } - let section_len = match cursor.read_u32::() { - Ok(v) => v, - Err(_) => break, - }; - let section_start = cursor.position() as usize; - - match §ion_magic { - b"DIDX" => { - let entry_count = section_len / 12; - entries.reserve(entry_count as usize); - for _ in 0..entry_count { - let file_id = cursor - .read_u32::() - .map_err(|e| format!("DIDX read error: {e}"))?; - let offset = cursor - .read_u32::() - .map_err(|e| format!("DIDX read error: {e}"))?; - let length = cursor - .read_u32::() - .map_err(|e| format!("DIDX read error: {e}"))?; - entries.push((file_id, offset, length)); - } - } - b"DATA" => { - data_section_offset = section_start; - } - b"HIRC" => { - let hirc_end = section_start + section_len as usize; - if hirc_end <= data.len() { - hirc_bytes = Some(data[section_start..hirc_end].to_vec()); - } - } - _ => {} - } - - cursor.set_position((section_start + section_len as usize) as u64); - } - - Ok(BnkFile { - version, - bank_id, - entries, - data_section_offset, - hirc_bytes, - }) - } - - pub fn info(&self) -> BnkInfo { - BnkInfo { - format: "bnk".into(), - version: self.version, - bank_id: self.bank_id, - entry_count: self.entries.len(), - entries: self - .entries - .iter() - .map(|&(id, _, size)| AudioEntryInfo { id, size }) - .collect(), - has_hirc: self.hirc_bytes.is_some(), - } - } - - pub fn read_entry_data<'a>(&self, data: &'a [u8], file_id: u32) -> Result<&'a [u8], String> { - let entry = self - .entries - .iter() - .find(|e| e.0 == file_id) - .ok_or_else(|| format!("Audio entry {file_id} not found in BNK"))?; - - let start = self.data_section_offset + entry.1 as usize; - let end = start + entry.2 as usize; - - if end > data.len() { - return Err(format!( - "Audio entry {file_id} data out of bounds ({}..{} > {})", - start, - end, - data.len() - )); - } - - Ok(&data[start..end]) - } - - pub fn read_all_entries(&self, data: &[u8]) -> Result, String> { - let mut result = Vec::with_capacity(self.entries.len()); - for &(id, offset, length) in &self.entries { - let start = self.data_section_offset + offset as usize; - let end = start + length as usize; - if end > data.len() { - return Err(format!("Audio entry {id} data out of bounds")); - } - result.push(AudioEntry { - id, - data: data[start..end].to_vec(), - }); - } - Ok(result) - } -} - -pub fn parse_bnk_metadata(data: &[u8]) -> Result { - let bnk = BnkFile::parse(data)?; - Ok(bnk.info()) -} - -pub fn read_bnk_entry(data: &[u8], file_id: u32) -> Result, String> { - let bnk = BnkFile::parse(data)?; - Ok(bnk.read_entry_data(data, file_id)?.to_vec()) -} - -// --------------------------------------------------------------------------- -// Writer -// --------------------------------------------------------------------------- - -const ALIGNMENT: usize = 16; - -fn align_up(val: usize, alignment: usize) -> usize { - let rem = val % alignment; - if rem == 0 { - val - } else { - val + (alignment - rem) - } -} - -/// Produces a minimal BKHD + DIDX + DATA bank (version 0x86). -pub fn write_bnk(entries: &[AudioEntry]) -> Vec { - let bkhd_section_len: u32 = 0x14; - - let didx_size = (entries.len() * 12) as u32; - - let mut offsets = Vec::with_capacity(entries.len()); - let mut data_size: usize = 0; - for entry in entries { - let aligned = align_up(data_size, ALIGNMENT); - offsets.push(aligned as u32); - data_size = aligned + entry.data.len(); - } - - let total = 8 + bkhd_section_len as usize + 8 + didx_size as usize + 8 + data_size; - - let mut buf = Vec::with_capacity(total); - - buf.write_all(b"BKHD").unwrap(); - buf.write_u32::(bkhd_section_len).unwrap(); - buf.write_u32::(0x86).unwrap(); - buf.write_u32::(0).unwrap(); - buf.write_u32::(0x17705D3E).unwrap(); - buf.write_all(&[0u8; 8]).unwrap(); - - buf.write_all(b"DIDX").unwrap(); - buf.write_u32::(didx_size).unwrap(); - for (i, entry) in entries.iter().enumerate() { - buf.write_u32::(entry.id).unwrap(); - buf.write_u32::(offsets[i]).unwrap(); - buf.write_u32::(entry.data.len() as u32) - .unwrap(); - } - - buf.write_all(b"DATA").unwrap(); - buf.write_u32::(data_size as u32).unwrap(); - let data_start = buf.len(); - buf.resize(data_start + data_size, 0); - for (i, entry) in entries.iter().enumerate() { - let dst = data_start + offsets[i] as usize; - buf[dst..dst + entry.data.len()].copy_from_slice(&entry.data); - } - - buf -} - -pub fn replace_bnk_entry(data: &[u8], file_id: u32, new_wem: &[u8]) -> Result, String> { - let bnk = BnkFile::parse(data)?; - let mut entries = bnk.read_all_entries(data)?; - - let entry = entries - .iter_mut() - .find(|e| e.id == file_id) - .ok_or_else(|| format!("Entry {file_id} not found"))?; - entry.data = new_wem.to_vec(); - - Ok(write_bnk(&entries)) -} - -/// Minimal silence WEM (valid RIFF WAV with no audio data). -pub const SILENCE_WEM: &[u8] = &[ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // chunk size = 36 - 0x57, 0x41, 0x56, 0x45, // "WAVE" - 0x66, 0x6D, 0x74, 0x20, // "fmt " - 0x10, 0x00, 0x00, 0x00, // fmt size = 16 - 0x01, 0x00, // PCM format - 0x01, 0x00, // 1 channel - 0x44, 0xAC, 0x00, 0x00, // 44100 Hz - 0x88, 0x58, 0x01, 0x00, // byte rate - 0x02, 0x00, // block align - 0x10, 0x00, // 16 bits per sample - 0x64, 0x61, 0x74, 0x61, // "data" - 0x00, 0x00, 0x00, 0x00, // data size = 0 (silence) -]; - -pub fn silence_bnk_entry(data: &[u8], file_id: u32) -> Result, String> { - replace_bnk_entry(data, file_id, SILENCE_WEM) -} - -/// Rewrites BKHD/DIDX/DATA — HIRC is not preserved. -pub fn remove_bnk_entry(data: &[u8], file_id: u32) -> Result, String> { - let bnk = BnkFile::parse(data)?; - let mut entries = bnk.read_all_entries(data)?; - let before = entries.len(); - entries.retain(|e| e.id != file_id); - if entries.len() == before { - return Err(format!("Entry {file_id} not found")); - } - Ok(write_bnk(&entries)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_bnk() -> Vec { - let e1 = AudioEntry { - id: 100, - data: vec![0xAA; 32], - }; - let e2 = AudioEntry { - id: 200, - data: vec![0xBB; 16], - }; - write_bnk(&[e1, e2]) - } - - #[test] - fn roundtrip_write_parse() { - let bnk_data = make_test_bnk(); - let info = parse_bnk_metadata(&bnk_data).unwrap(); - assert_eq!(info.format, "bnk"); - assert_eq!(info.entry_count, 2); - assert_eq!(info.entries[0].id, 100); - assert_eq!(info.entries[0].size, 32); - assert_eq!(info.entries[1].id, 200); - assert_eq!(info.entries[1].size, 16); - } - - #[test] - fn read_entry_data() { - let bnk_data = make_test_bnk(); - let entry_data = read_bnk_entry(&bnk_data, 100).unwrap(); - assert_eq!(entry_data.len(), 32); - assert!(entry_data.iter().all(|&b| b == 0xAA)); - - let entry_data = read_bnk_entry(&bnk_data, 200).unwrap(); - assert_eq!(entry_data.len(), 16); - assert!(entry_data.iter().all(|&b| b == 0xBB)); - } - - #[test] - fn replace_entry() { - let bnk_data = make_test_bnk(); - let new_bnk = replace_bnk_entry(&bnk_data, 100, &[0xCC; 64]).unwrap(); - let info = parse_bnk_metadata(&new_bnk).unwrap(); - assert_eq!(info.entries[0].size, 64); - let entry_data = read_bnk_entry(&new_bnk, 100).unwrap(); - assert!(entry_data.iter().all(|&b| b == 0xCC)); - } - - #[test] - fn entry_not_found() { - let bnk_data = make_test_bnk(); - assert!(read_bnk_entry(&bnk_data, 999).is_err()); - } -} diff --git a/src-tauri/crates/quartz-lib/src/audio/decode.rs b/src-tauri/crates/quartz-lib/src/audio/decode.rs new file mode 100644 index 00000000..a48a68cd --- /dev/null +++ b/src-tauri/crates/quartz-lib/src/audio/decode.rs @@ -0,0 +1,162 @@ +/* Decoding user-supplied audio files down to PCM samples. + +rs_audio deliberately stops at PCM in both directions — reading mp3/flac/ogg is the application's +job — so this is where the app meets it. Everything a normal editor exports is handled in-process, +which is what let the external decoder binary go. */ + +use ritoshark::audio::PcmAudio; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::conv::IntoSample; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +/** Decodes MP3, FLAC, OGG/Vorbis, WAV or M4A/AAC into interleaved 16-bit samples. + +The container is identified from the bytes rather than a file extension, so a mislabelled file +still decodes and a renamed one does not silently take the wrong path. */ +pub fn decode_any(data: &[u8]) -> Result { + let source = MediaSourceStream::new(Box::new(std::io::Cursor::new(data.to_vec())), <_>::default()); + + let probed = symphonia::default::get_probe() + .format( + &Hint::new(), + source, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|_| { + "Unsupported audio file. Supported: WAV, MP3, OGG, FLAC, M4A and .wem.".to_string() + })?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or("Audio file contains no decodable track")?; + let track_id = track.id; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| format!("No decoder for this audio codec: {e}"))?; + + let mut samples: Vec = Vec::new(); + let mut sample_rate = track.codec_params.sample_rate.unwrap_or(0); + let mut channels = track + .codec_params + .channels + .map(|c| c.count() as u16) + .unwrap_or(0); + + // The loop ends on the first packet error, which covers both a clean end of + // stream and a truncated tail — whatever decoded before it is still usable. + while let Ok(packet) = format.next_packet() { + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(buffer) => { + let spec = *buffer.spec(); + if sample_rate == 0 { + sample_rate = spec.rate; + } + if channels == 0 { + channels = spec.channels.count() as u16; + } + append_interleaved(&buffer, &mut samples); + } + // A damaged packet costs one packet, not the whole file. + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(e) => return Err(format!("Audio decode failed: {e}")), + } + } + + if samples.is_empty() { + return Err("Audio file decoded to no samples".into()); + } + if sample_rate == 0 || channels == 0 { + return Err("Audio file declares no sample rate or no channels".into()); + } + + Ok(PcmAudio::new(sample_rate, channels, samples)) +} + +/// Flattens a planar decode buffer into the interleaved layout the encoder wants. +fn append_interleaved(buffer: &AudioBufferRef<'_>, out: &mut Vec) { + macro_rules! planar { + ($buf:expr) => {{ + let channels = $buf.spec().channels.count(); + let frames = $buf.frames(); + out.reserve(frames * channels); + for frame in 0..frames { + for channel in 0..channels { + out.push($buf.chan(channel)[frame].into_sample()); + } + } + }}; + } + + match buffer { + AudioBufferRef::U8(b) => planar!(b), + AudioBufferRef::U16(b) => planar!(b), + AudioBufferRef::U24(b) => planar!(b), + AudioBufferRef::U32(b) => planar!(b), + AudioBufferRef::S8(b) => planar!(b), + AudioBufferRef::S16(b) => planar!(b), + AudioBufferRef::S24(b) => planar!(b), + AudioBufferRef::S32(b) => planar!(b), + AudioBufferRef::F32(b) => planar!(b), + AudioBufferRef::F64(b) => planar!(b), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wav(sample_rate: u32, channels: u16, frames: &[i16]) -> Vec { + let data_len = frames.len() * 2; + let mut out = Vec::new(); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_len as u32).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&channels.to_le_bytes()); + out.extend_from_slice(&sample_rate.to_le_bytes()); + out.extend_from_slice(&(sample_rate * u32::from(channels) * 2).to_le_bytes()); + out.extend_from_slice(&(channels * 2).to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data_len as u32).to_le_bytes()); + for sample in frames { + out.extend_from_slice(&sample.to_le_bytes()); + } + out + } + + #[test] + fn a_wav_decodes_to_the_samples_it_declares() { + let decoded = decode_any(&wav(44100, 1, &[0, 1000, -1000, 32767])).unwrap(); + assert_eq!(decoded.sample_rate, 44100); + assert_eq!(decoded.channels, 1); + assert_eq!(decoded.samples, vec![0, 1000, -1000, 32767]); + } + + #[test] + fn stereo_stays_interleaved() { + let decoded = decode_any(&wav(22050, 2, &[1, -1, 2, -2])).unwrap(); + assert_eq!(decoded.channels, 2); + assert_eq!(decoded.samples, vec![1, -1, 2, -2]); + } + + #[test] + fn a_non_audio_file_is_an_error_rather_than_silence() { + assert!(decode_any(b"not audio, just some bytes here at all").is_err()); + assert!(decode_any(&[]).is_err()); + } +} diff --git a/src-tauri/crates/quartz-lib/src/audio/mod.rs b/src-tauri/crates/quartz-lib/src/audio/mod.rs index 21ad6279..16f9c95a 100644 --- a/src-tauri/crates/quartz-lib/src/audio/mod.rs +++ b/src-tauri/crates/quartz-lib/src/audio/mod.rs @@ -1,6 +1,5 @@ -pub mod bnk; +pub mod bank; +pub mod decode; pub mod event_mapper; pub mod hirc; pub mod tree; -pub mod wem; -pub mod wpk; diff --git a/src-tauri/crates/quartz-lib/src/audio/tree.rs b/src-tauri/crates/quartz-lib/src/audio/tree.rs index 30310485..6d19f94c 100644 --- a/src-tauri/crates/quartz-lib/src/audio/tree.rs +++ b/src-tauri/crates/quartz-lib/src/audio/tree.rs @@ -6,10 +6,9 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use super::bnk::{self, AudioEntry}; +use super::bank::{self, AudioEntry}; use super::event_mapper::{self, EventMapping}; use super::hirc; -use super::wpk; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -368,14 +367,7 @@ fn file_name(path: &str) -> String { /// Read all entries from a BNK or WPK buffer, sorted by id. fn read_entries(data: &[u8]) -> Result, String> { - if data.len() < 4 { - return Err("Audio file too small".into()); - } - let mut entries = match &data[0..4] { - b"BKHD" => bnk::BnkFile::parse(data)?.read_all_entries(data)?, - b"r3d2" => wpk::WpkFile::parse(data)?.read_all_entries(data)?, - _ => return Err("Unknown audio format".into()), - }; + let mut entries = bank::all_entries(data)?; entries.sort_by_key(|e| e.id); Ok(entries) } diff --git a/src-tauri/crates/quartz-lib/src/audio/wem.rs b/src-tauri/crates/quartz-lib/src/audio/wem.rs deleted file mode 100644 index 1cb3ce6f..00000000 --- a/src-tauri/crates/quartz-lib/src/audio/wem.rs +++ /dev/null @@ -1,1376 +0,0 @@ -//! WEM → OGG/WAV decoder (Rust port of ww2ogg). -//! -//! Converts Wwise WEM audio files (RIFF-wrapped Vorbis) to standard OGG Vorbis -//! or WAV PCM that browsers can play via Web Audio API. - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DecodedAudio { - pub data: Vec, - pub format: String, // "ogg" or "wav" - pub sample_rate: Option, -} - -const CODEBOOK_DATA: &[u8] = include_bytes!("../../resources/packed_codebooks_aoTuV_603.bin"); - -/// The packed Vorbis codebook embedded in the decoder. -pub fn codebook_bytes() -> &'static [u8] { - CODEBOOK_DATA -} - -// --------------------------------------------------------------------------- -// OGG CRC lookup table -// --------------------------------------------------------------------------- - -#[rustfmt::skip] -const CRC_LOOKUP: [u32; 256] = [ - 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, - 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, - 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, - 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, - 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, - 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, - 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, - 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd, - 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, - 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, - 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, - 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, - 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, - 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95, - 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, - 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, - 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, - 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, - 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, - 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca, - 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, - 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, - 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, - 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, - 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, - 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692, - 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, - 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, - 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, - 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, - 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, - 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a, - 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, - 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, - 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, - 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, - 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, - 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b, - 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, - 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, - 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, - 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, - 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, - 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3, - 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, - 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, - 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, - 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, - 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, - 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c, - 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, - 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, - 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, - 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, - 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, - 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654, - 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, - 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, - 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, - 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, - 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, - 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c, - 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, - 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4, -]; - -fn ogg_checksum(data: &[u8]) -> u32 { - let mut crc: u32 = 0; - for &b in data { - crc = (crc << 8) ^ CRC_LOOKUP[((crc >> 24) as u8 ^ b) as usize]; - } - crc -} - -// --------------------------------------------------------------------------- -// Helper: read LE integers from byte slices -// --------------------------------------------------------------------------- - -fn read_u16_le(data: &[u8], off: usize) -> u16 { - u16::from_le_bytes([data[off], data[off + 1]]) -} - -fn read_u32_le(data: &[u8], off: usize) -> u32 { - u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) -} - -fn write_u16_le(data: &mut [u8], off: usize, val: u16) { - let bytes = val.to_le_bytes(); - data[off] = bytes[0]; - data[off + 1] = bytes[1]; -} - -fn write_u32_le(data: &mut [u8], off: usize, val: u32) { - let bytes = val.to_le_bytes(); - data[off] = bytes[0]; - data[off + 1] = bytes[1]; - data[off + 2] = bytes[2]; - data[off + 3] = bytes[3]; -} - -// --------------------------------------------------------------------------- -// BitReader — reads bits LSB-first from a byte stream -// --------------------------------------------------------------------------- - -struct BitReader<'a> { - data: &'a [u8], - byte_offset: usize, - bit_buffer: u8, - bits_left: u8, - total_bits_read: usize, -} - -impl<'a> BitReader<'a> { - fn new(data: &'a [u8], initial_offset: usize) -> Self { - Self { - data, - byte_offset: initial_offset, - bit_buffer: 0, - bits_left: 0, - total_bits_read: 0, - } - } - - fn get_bit(&mut self) -> Result { - if self.bits_left == 0 { - let pos = self.byte_offset + self.total_bits_read / 8; - if pos >= self.data.len() { - return Err("BitReader: out of bits".into()); - } - self.bit_buffer = self.data[pos]; - self.bits_left = 8; - } - self.total_bits_read += 1; - self.bits_left -= 1; - Ok(if (self.bit_buffer & (0x80 >> self.bits_left)) != 0 { - 1 - } else { - 0 - }) - } - - fn read_bits(&mut self, count: u32) -> Result { - let mut value: u32 = 0; - for i in 0..count { - if self.get_bit()? != 0 { - value |= 1 << i; - } - } - Ok(value) - } - - fn total_bits_read(&self) -> usize { - self.total_bits_read - } -} - -// --------------------------------------------------------------------------- -// BitOggWriter — writes bits and constructs OGG pages -// --------------------------------------------------------------------------- - -struct BitOggWriter { - output: Vec, - bit_buffer: u8, - bits_stored: u8, - payload_bytes: usize, - first: bool, - continued: bool, - granule: u32, - seqno: u32, - page_buffer: Vec, -} - -impl BitOggWriter { - fn new() -> Self { - Self { - output: Vec::new(), - bit_buffer: 0, - bits_stored: 0, - payload_bytes: 0, - first: true, - continued: false, - granule: 0, - seqno: 0, - page_buffer: vec![0u8; 27 + 255 + 255 * 255], - } - } - - fn put_bit(&mut self, bit: bool) { - if bit { - self.bit_buffer |= 1 << self.bits_stored; - } - self.bits_stored += 1; - if self.bits_stored == 8 { - self.flush_bits(); - } - } - - fn write_bits(&mut self, value: u32, count: u32) { - for i in 0..count { - self.put_bit((value & (1 << i)) != 0); - } - } - - fn set_granule(&mut self, g: u32) { - self.granule = g; - } - - fn flush_bits(&mut self) { - if self.bits_stored != 0 { - if self.payload_bytes == 255 * 255 { - self.flush_page(true, false); - } - self.page_buffer[27 + 255 + self.payload_bytes] = self.bit_buffer; - self.payload_bytes += 1; - self.bits_stored = 0; - self.bit_buffer = 0; - } - } - - fn flush_page(&mut self, next_continued: bool, last: bool) { - if self.payload_bytes != 255 * 255 { - self.flush_bits(); - } - - if self.payload_bytes == 0 { - return; - } - - let segment_size: usize = 255; - let mut segments = self.payload_bytes.div_ceil(segment_size); - if segments == 256 { - segments = 255; - } - - for i in 0..self.payload_bytes { - self.page_buffer[27 + segments + i] = self.page_buffer[27 + 255 + i]; - } - - self.page_buffer[0] = b'O'; - self.page_buffer[1] = b'g'; - self.page_buffer[2] = b'g'; - self.page_buffer[3] = b'S'; - self.page_buffer[4] = 0; // stream_structure_version - self.page_buffer[5] = (if self.continued { 1 } else { 0 }) - | (if self.first { 2 } else { 0 }) - | (if last { 4 } else { 0 }); - - // Granule position (64 bits) - write_u32_le(&mut self.page_buffer, 6, self.granule); - if self.granule == 0xFFFFFFFF { - write_u32_le(&mut self.page_buffer, 10, 0xFFFFFFFF); - } else { - write_u32_le(&mut self.page_buffer, 10, 0); - } - - write_u32_le(&mut self.page_buffer, 14, 1); // stream serial number - write_u32_le(&mut self.page_buffer, 18, self.seqno); // page sequence - write_u32_le(&mut self.page_buffer, 22, 0); // checksum placeholder - self.page_buffer[26] = segments as u8; // segment count - - // Lacing values - let mut bytes_left = self.payload_bytes; - for i in 0..segments { - if bytes_left >= segment_size { - bytes_left -= segment_size; - self.page_buffer[27 + i] = segment_size as u8; - } else { - self.page_buffer[27 + i] = bytes_left as u8; - } - } - - let page_size = 27 + segments + self.payload_bytes; - let crc = ogg_checksum(&self.page_buffer[..page_size]); - write_u32_le(&mut self.page_buffer, 22, crc); - - self.output - .extend_from_slice(&self.page_buffer[..page_size]); - - self.seqno += 1; - self.first = false; - self.continued = next_continued; - self.payload_bytes = 0; - } - - fn get_output(mut self) -> Vec { - self.flush_page(false, false); - self.output - } -} - -// --------------------------------------------------------------------------- -// Codebook library -// --------------------------------------------------------------------------- - -struct CodebookLibrary { - codebook_data: Vec, - codebook_offsets: Vec, - codebook_count: usize, -} - -impl CodebookLibrary { - fn load(data: &[u8]) -> Result { - if data.len() < 8 { - return Err("Codebook data too small".into()); - } - - let len = data.len(); - let offset_offset = read_u32_le(data, len - 4) as usize; - - if offset_offset >= len { - return Err("Invalid codebook offset table position".into()); - } - - let num_offsets = (len - offset_offset) / 4; - if num_offsets == 0 { - return Err("No codebook offsets found".into()); - } - let codebook_count = num_offsets - 1; - - let codebook_data = data[..offset_offset].to_vec(); - let mut codebook_offsets = Vec::with_capacity(num_offsets); - for i in 0..num_offsets { - let pos = offset_offset + i * 4; - codebook_offsets.push(read_u32_le(data, pos)); - } - - Ok(Self { - codebook_data, - codebook_offsets, - codebook_count, - }) - } - - fn get_codebook(&self, id: usize) -> Result<&[u8], String> { - if id >= self.codebook_count { - return Err(format!( - "Invalid codebook id: {id} (max {})", - self.codebook_count - 1 - )); - } - let start = self.codebook_offsets[id] as usize; - let end = self.codebook_offsets[id + 1] as usize; - Ok(&self.codebook_data[start..end]) - } - - fn rebuild_from_id(&self, id: usize, bos: &mut BitOggWriter) -> Result<(), String> { - let cb = self.get_codebook(id)?; - let mut bis = BitReader::new(cb, 0); - Self::rebuild(&mut bis, bos) - } - - fn rebuild(bis: &mut BitReader, bos: &mut BitOggWriter) -> Result<(), String> { - let dimensions = bis.read_bits(4)?; - let entries = bis.read_bits(14)?; - - bos.write_bits(0x564342, 24); - bos.write_bits(dimensions, 16); - bos.write_bits(entries, 24); - - let ordered = bis.read_bits(1)?; - bos.write_bits(ordered, 1); - - if ordered != 0 { - let initial_length = bis.read_bits(5)?; - bos.write_bits(initial_length, 5); - - let mut current_entry: u32 = 0; - while current_entry < entries { - let num_bits = ilog(entries - current_entry); - let number = bis.read_bits(num_bits)?; - bos.write_bits(number, num_bits); - current_entry += number; - } - if current_entry > entries { - return Err("current_entry out of range".into()); - } - } else { - let codeword_length_length = bis.read_bits(3)?; - let sparse = bis.read_bits(1)?; - - if codeword_length_length == 0 || codeword_length_length > 5 { - return Err("nonsense codeword length".into()); - } - - bos.write_bits(sparse, 1); - - for _ in 0..entries { - let mut present_bool = true; - if sparse != 0 { - let present = bis.read_bits(1)?; - bos.write_bits(present, 1); - present_bool = present != 0; - } - if present_bool { - let codeword_length = bis.read_bits(codeword_length_length)?; - bos.write_bits(codeword_length, 5); - } - } - } - - let lookup_type = bis.read_bits(1)?; - bos.write_bits(lookup_type, 4); - - if lookup_type == 1 { - let min = bis.read_bits(32)?; - let max = bis.read_bits(32)?; - let value_length = bis.read_bits(4)?; - let sequence_flag = bis.read_bits(1)?; - - bos.write_bits(min, 32); - bos.write_bits(max, 32); - bos.write_bits(value_length, 4); - bos.write_bits(sequence_flag, 1); - - let quantvals = book_maptype1_quantvals(entries, dimensions); - for _ in 0..quantvals { - let val = bis.read_bits(value_length + 1)?; - bos.write_bits(val, value_length + 1); - } - } else if lookup_type != 0 { - return Err("Invalid lookup type".into()); - } - - Ok(()) - } - - fn copy_codebook(bis: &mut BitReader, bos: &mut BitOggWriter) -> Result<(), String> { - let id = bis.read_bits(24)?; - let dimensions = bis.read_bits(16)?; - let entries = bis.read_bits(24)?; - - if id != 0x564342 { - return Err("Invalid codebook identifier".into()); - } - - bos.write_bits(id, 24); - bos.write_bits(dimensions, 16); - bos.write_bits(entries, 24); - - let ordered = bis.read_bits(1)?; - bos.write_bits(ordered, 1); - - if ordered != 0 { - let initial_length = bis.read_bits(5)?; - bos.write_bits(initial_length, 5); - - let mut current_entry: u32 = 0; - while current_entry < entries { - let num_bits = ilog(entries - current_entry); - let number = bis.read_bits(num_bits)?; - bos.write_bits(number, num_bits); - current_entry += number; - } - } else { - let sparse = bis.read_bits(1)?; - bos.write_bits(sparse, 1); - - for _ in 0..entries { - let mut present_bool = true; - if sparse != 0 { - let present = bis.read_bits(1)?; - bos.write_bits(present, 1); - present_bool = present != 0; - } - if present_bool { - let codeword_length = bis.read_bits(5)?; - bos.write_bits(codeword_length, 5); - } - } - } - - let lookup_type = bis.read_bits(4)?; - bos.write_bits(lookup_type, 4); - - if lookup_type == 1 { - let min = bis.read_bits(32)?; - let max = bis.read_bits(32)?; - let value_length = bis.read_bits(4)?; - let sequence_flag = bis.read_bits(1)?; - - bos.write_bits(min, 32); - bos.write_bits(max, 32); - bos.write_bits(value_length, 4); - bos.write_bits(sequence_flag, 1); - - let quantvals = book_maptype1_quantvals(entries, dimensions); - for _ in 0..quantvals { - let val = bis.read_bits(value_length + 1)?; - bos.write_bits(val, value_length + 1); - } - } else if lookup_type == 2 { - return Err("Didn't expect lookup type 2".into()); - } else if lookup_type != 0 { - return Err("Invalid lookup type".into()); - } - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Helper math -// --------------------------------------------------------------------------- - -fn ilog(v: u32) -> u32 { - if v == 0 { - return 0; - } - 32 - v.leading_zeros() -} - -fn book_maptype1_quantvals(entries: u32, dimensions: u32) -> u32 { - if dimensions == 0 || entries == 0 { - return 0; - } - let bits = ilog(entries); - let mut vals = entries >> ((bits - 1) * (dimensions - 1) / dimensions); - - loop { - let mut acc: u64 = 1; - let mut acc1: u64 = 1; - for _ in 0..dimensions { - acc *= vals as u64; - acc1 *= (vals + 1) as u64; - } - if acc <= entries as u64 && acc1 > entries as u64 { - return vals; - } - if acc > entries as u64 { - vals -= 1; - } else { - vals += 1; - } - } -} - -// --------------------------------------------------------------------------- -// Packet header readers -// --------------------------------------------------------------------------- - -struct PacketHeader { - header_size: usize, - size: usize, - granule: u32, -} - -impl PacketHeader { - /// Modern 2 or 6 byte header - fn read(data: &[u8], offset: usize, no_granule: bool) -> Result { - if offset + 2 > data.len() { - return Err("Packet header truncated".into()); - } - let size = read_u16_le(data, offset) as usize; - let (header_size, granule) = if no_granule { - (2, 0) - } else { - if offset + 6 > data.len() { - return Err("Packet header truncated (6-byte)".into()); - } - (6, read_u32_le(data, offset + 2)) - }; - Ok(Self { - header_size, - size, - granule, - }) - } - - /// Old 8-byte header - fn read_old(data: &[u8], offset: usize) -> Result { - if offset + 8 > data.len() { - return Err("Old packet header truncated".into()); - } - Ok(Self { - header_size: 8, - size: read_u32_le(data, offset) as usize, - granule: read_u32_le(data, offset + 4), - }) - } - - fn payload_offset(&self, base: usize) -> usize { - base + self.header_size - } - - fn next_offset(&self, base: usize) -> usize { - base + self.header_size + self.size - } -} - -// --------------------------------------------------------------------------- -// WwiseRiffVorbis — main converter -// --------------------------------------------------------------------------- - -struct WwiseRiffVorbis<'a> { - data: &'a [u8], - is_wav: bool, - - data_offset: usize, - data_size: usize, - - channels: u32, - sample_rate: u32, - avg_bytes_per_second: u32, - block_align: u16, - bits_per_sample: u16, - - setup_packet_offset: u32, - first_audio_packet_offset: u32, - blocksize_0_pow: u8, - blocksize_1_pow: u8, - - header_triad_present: bool, - old_packet_headers: bool, - no_granule: bool, - mod_packets: bool, - - loop_count: u32, - loop_start: u32, - loop_end: u32, - - codebook_lib: &'a CodebookLibrary, -} - -impl<'a> WwiseRiffVorbis<'a> { - fn parse(data: &'a [u8], codebook_lib: &'a CodebookLibrary) -> Result { - if data.len() < 12 { - return Err("Data too small for RIFF".into()); - } - - let magic = &data[0..4]; - if magic != b"RIFF" && magic != b"RIFX" { - return Err("Missing RIFF header".into()); - } - if magic == b"RIFX" { - return Err("RIFX (big-endian) not supported".into()); - } - - let riff_size = read_u32_le(data, 4) as usize + 8; - if riff_size > data.len() { - return Err("RIFF truncated".into()); - } - - if &data[8..12] != b"WAVE" { - return Err("Missing WAVE header".into()); - } - - let mut fmt_offset = 0usize; - let mut fmt_size = 0usize; - let mut smpl_offset = 0usize; - let mut vorb_offset = 0usize; - let mut vorb_size: i32 = -1; - let mut data_offset = 0usize; - let mut data_size = 0usize; - - let mut chunk_offset = 12usize; - while chunk_offset + 8 <= riff_size { - let chunk_type = &data[chunk_offset..chunk_offset + 4]; - let chunk_len = read_u32_le(data, chunk_offset + 4) as usize; - - match chunk_type { - b"fmt " => { - fmt_offset = chunk_offset + 8; - fmt_size = chunk_len; - } - b"smpl" => { - smpl_offset = chunk_offset + 8; - } - b"vorb" => { - vorb_offset = chunk_offset + 8; - vorb_size = chunk_len as i32; - } - b"data" => { - data_offset = chunk_offset + 8; - data_size = chunk_len; - } - _ => {} - } - - chunk_offset += 8 + chunk_len; - } - - if fmt_offset == 0 || data_offset == 0 { - return Err("Expected fmt, data chunks".into()); - } - - let mut is_wav = false; - - if vorb_offset == 0 { - if fmt_size == 0x18 { - is_wav = true; - } else if fmt_size == 0x42 { - vorb_offset = fmt_offset + 0x18; - vorb_size = -1; - } else { - return Err("Expected fmt_size of 0x18 or 0x42 if vorb section missing".into()); - } - } - - let codec_id = read_u16_le(data, fmt_offset); - if is_wav { - if codec_id != 0xFFFE { - return Err(format!("Bad codec id for WAV: 0x{codec_id:04X}")); - } - } else if codec_id != 0xFFFF { - return Err(format!("Bad codec id: 0x{codec_id:04X}")); - } - - let channels = read_u16_le(data, fmt_offset + 2) as u32; - let sample_rate = read_u32_le(data, fmt_offset + 4); - let avg_bytes_per_second = read_u32_le(data, fmt_offset + 8); - let block_align = read_u16_le(data, fmt_offset + 12); - let bits_per_sample = read_u16_le(data, fmt_offset + 14); - - if is_wav { - return Ok(Self { - data, - is_wav: true, - data_offset, - data_size, - channels, - sample_rate, - avg_bytes_per_second, - block_align, - bits_per_sample, - setup_packet_offset: 0, - first_audio_packet_offset: 0, - blocksize_0_pow: 0, - blocksize_1_pow: 0, - header_triad_present: false, - old_packet_headers: false, - no_granule: false, - mod_packets: false, - loop_count: 0, - loop_start: 0, - loop_end: 0, - codebook_lib, - }); - } - - let mut loop_count = 0u32; - let mut loop_start = 0u32; - let mut loop_end = 0u32; - if smpl_offset != 0 { - loop_count = read_u32_le(data, smpl_offset + 0x1C); - if loop_count == 1 { - loop_start = read_u32_le(data, smpl_offset + 0x2C); - loop_end = read_u32_le(data, smpl_offset + 0x30); - } - } - - let valid_vorb_sizes: &[i32] = &[-1, 0x28, 0x2A, 0x2C, 0x32, 0x34]; - if !valid_vorb_sizes.contains(&vorb_size) { - return Err(format!("Bad vorb size: {vorb_size}")); - } - - let sample_count = read_u32_le(data, vorb_offset); - - let mut no_granule = false; - let mut mod_packets = false; - let mut header_triad_present = false; - let mut old_packet_headers = false; - let mut blocksize_0_pow: u8 = 0; - let mut blocksize_1_pow: u8 = 0; - - let file_pos; - if vorb_size == -1 || vorb_size == 0x2A { - no_granule = true; - let mod_signal = read_u32_le(data, vorb_offset + 0x4); - if mod_signal != 0x4A && mod_signal != 0x4B && mod_signal != 0x69 && mod_signal != 0x70 - { - mod_packets = true; - } - file_pos = vorb_offset + 0x10; - } else { - file_pos = vorb_offset + 0x18; - } - - let setup_packet_offset = read_u32_le(data, file_pos); - let first_audio_packet_offset = read_u32_le(data, file_pos + 4); - - if vorb_size == 0x28 || vorb_size == 0x2C { - header_triad_present = true; - old_packet_headers = true; - } else { - let bp_pos = if vorb_size == -1 || vorb_size == 0x2A { - vorb_offset + 0x24 - } else { - vorb_offset + 0x2C - }; - // uid skipped - blocksize_0_pow = data[bp_pos + 4]; - blocksize_1_pow = data[bp_pos + 5]; - } - - if loop_count != 0 { - if loop_end == 0 { - loop_end = sample_count; - } else { - loop_end += 1; - } - } - - Ok(Self { - data, - is_wav: false, - data_offset, - data_size, - channels, - sample_rate, - avg_bytes_per_second, - block_align, - bits_per_sample, - setup_packet_offset, - first_audio_packet_offset, - blocksize_0_pow, - blocksize_1_pow, - header_triad_present, - old_packet_headers, - no_granule, - mod_packets, - loop_count, - loop_start, - loop_end, - codebook_lib, - }) - } - - fn generate_ogg(&self) -> Result, String> { - if self.is_wav { - return Ok(self.generate_wav()); - } - - let mut os = BitOggWriter::new(); - let mut mode_blockflag: Option> = None; - let mut mode_bits: u32 = 0; - let mut prev_blockflag = false; - - if self.header_triad_present { - self.generate_ogg_header_with_triad(&mut os)?; - } else { - let result = self.generate_ogg_header(&mut os)?; - mode_blockflag = Some(result.0); - mode_bits = result.1; - } - - let mut offset = self.data_offset + self.first_audio_packet_offset as usize; - let data_end = self.data_offset + self.data_size; - - while offset < data_end { - let packet = if self.old_packet_headers { - PacketHeader::read_old(self.data, offset)? - } else { - PacketHeader::read(self.data, offset, self.no_granule)? - }; - - let size = packet.size; - let payload_offset = packet.payload_offset(offset); - let granule = packet.granule; - let next_offset = packet.next_offset(offset); - - if offset + packet.header_size > data_end { - return Err("Page header truncated".into()); - } - - if granule == 0xFFFFFFFF { - os.set_granule(1); - } else { - os.set_granule(granule); - } - - if self.mod_packets { - let mbf = mode_blockflag - .as_ref() - .ok_or("Didn't load mode_blockflag")?; - - // 1-bit packet type (0 = audio) - os.write_bits(0, 1); - - let mut ss = BitReader::new(self.data, payload_offset); - - let mode_number = ss.read_bits(mode_bits)?; - os.write_bits(mode_number, mode_bits); - - let remainder = ss.read_bits(8 - mode_bits)?; - - if mbf.get(mode_number as usize).copied().unwrap_or(false) { - // Long window — peek at next frame - let mut next_blockflag = false; - if next_offset + packet.header_size <= data_end { - let next_packet = - PacketHeader::read(self.data, next_offset, self.no_granule)?; - if next_packet.size > 0 { - let mut next_ss = - BitReader::new(self.data, next_packet.payload_offset(next_offset)); - let next_mode = next_ss.read_bits(mode_bits)?; - next_blockflag = mbf.get(next_mode as usize).copied().unwrap_or(false); - } - } - - os.write_bits(if prev_blockflag { 1 } else { 0 }, 1); - os.write_bits(if next_blockflag { 1 } else { 0 }, 1); - } - - prev_blockflag = mbf.get(mode_number as usize).copied().unwrap_or(false); - - os.write_bits(remainder, 8 - mode_bits); - } else { - os.write_bits(self.data[payload_offset] as u32, 8); - } - - for i in 1..size { - os.write_bits(self.data[payload_offset + i] as u32, 8); - } - - offset = next_offset; - os.flush_page(false, offset >= data_end); - } - - Ok(os.get_output()) - } - - fn generate_wav(&self) -> Vec { - let total_size = 44 + self.data_size; - let mut output = vec![0u8; total_size]; - - output[0..4].copy_from_slice(b"RIFF"); - write_u32_le(&mut output, 4, (total_size - 8) as u32); - output[8..12].copy_from_slice(b"WAVE"); - - output[12..16].copy_from_slice(b"fmt "); - write_u32_le(&mut output, 16, 16); - write_u16_le(&mut output, 20, 1); // PCM - write_u16_le(&mut output, 22, self.channels as u16); - write_u32_le(&mut output, 24, self.sample_rate); - write_u32_le(&mut output, 28, self.avg_bytes_per_second); - write_u16_le(&mut output, 32, self.block_align); - write_u16_le(&mut output, 34, self.bits_per_sample); - - output[36..40].copy_from_slice(b"data"); - write_u32_le(&mut output, 40, self.data_size as u32); - - let src = &self.data[self.data_offset..self.data_offset + self.data_size]; - output[44..44 + self.data_size].copy_from_slice(src); - - output - } - - fn generate_ogg_header(&self, os: &mut BitOggWriter) -> Result<(Vec, u32), String> { - self.write_vorbis_packet_header(os, 1); - - os.write_bits(0, 32); // version - os.write_bits(self.channels, 8); - os.write_bits(self.sample_rate, 32); - os.write_bits(0, 32); // bitrate max - os.write_bits(self.avg_bytes_per_second * 8, 32); // bitrate nominal - os.write_bits(0, 32); // bitrate min - os.write_bits(self.blocksize_0_pow as u32, 4); - os.write_bits(self.blocksize_1_pow as u32, 4); - os.write_bits(1, 1); // framing - - os.flush_page(false, false); - - self.write_vorbis_packet_header(os, 3); - - let vendor = b"converted from Audiokinetic Wwise by ww2ogg (Rust)"; - os.write_bits(vendor.len() as u32, 32); - for &b in vendor { - os.write_bits(b as u32, 8); - } - - if self.loop_count == 0 { - os.write_bits(0, 32); // no user comments - } else { - os.write_bits(2, 32); - - let ls = format!("LoopStart={}", self.loop_start); - os.write_bits(ls.len() as u32, 32); - for b in ls.bytes() { - os.write_bits(b as u32, 8); - } - - let le = format!("LoopEnd={}", self.loop_end); - os.write_bits(le.len() as u32, 32); - for b in le.bytes() { - os.write_bits(b as u32, 8); - } - } - - os.write_bits(1, 1); // framing - os.flush_page(false, false); - - self.write_vorbis_packet_header(os, 5); - - let setup_packet = PacketHeader::read( - self.data, - self.data_offset + self.setup_packet_offset as usize, - self.no_granule, - )?; - - if setup_packet.granule != 0 { - return Err("Setup packet granule != 0".into()); - } - - let setup_payload = - setup_packet.payload_offset(self.data_offset + self.setup_packet_offset as usize); - let mut ss = BitReader::new(self.data, setup_payload); - - let codebook_count_less1 = ss.read_bits(8)?; - let codebook_count = codebook_count_less1 + 1; - os.write_bits(codebook_count_less1, 8); - - for _ in 0..codebook_count { - let codebook_id = ss.read_bits(10)?; - self.codebook_lib - .rebuild_from_id(codebook_id as usize, os)?; - } - - // Time domain transforms - os.write_bits(0, 6); // count - 1 - os.write_bits(0, 16); // dummy - - let result = self.rebuild_setup(&mut ss, os, codebook_count)?; - - os.write_bits(1, 1); // framing - os.flush_page(false, false); - - Ok(result) - } - - fn rebuild_setup( - &self, - ss: &mut BitReader, - os: &mut BitOggWriter, - codebook_count: u32, - ) -> Result<(Vec, u32), String> { - let floor_count_less1 = ss.read_bits(6)?; - let floor_count = floor_count_less1 + 1; - os.write_bits(floor_count_less1, 6); - - for _ in 0..floor_count { - os.write_bits(1, 16); // floor type 1 - - let floor1_partitions = ss.read_bits(5)?; - os.write_bits(floor1_partitions, 5); - - let mut partition_class_list = Vec::new(); - let mut maximum_class: u32 = 0; - - for _ in 0..floor1_partitions { - let pc = ss.read_bits(4)?; - os.write_bits(pc, 4); - partition_class_list.push(pc); - if pc > maximum_class { - maximum_class = pc; - } - } - - let mut class_dimensions_list = Vec::new(); - - for _ in 0..=maximum_class { - let cd_less1 = ss.read_bits(3)?; - os.write_bits(cd_less1, 3); - class_dimensions_list.push(cd_less1 + 1); - - let class_subclasses = ss.read_bits(2)?; - os.write_bits(class_subclasses, 2); - - if class_subclasses != 0 { - let masterbook = ss.read_bits(8)?; - os.write_bits(masterbook, 8); - if masterbook >= codebook_count { - return Err("Invalid floor1 masterbook".into()); - } - } - - for _ in 0..(1u32 << class_subclasses) { - let sb = ss.read_bits(8)?; - os.write_bits(sb, 8); - } - } - - let multiplier_less1 = ss.read_bits(2)?; - os.write_bits(multiplier_less1, 2); - - let rangebits = ss.read_bits(4)?; - os.write_bits(rangebits, 4); - - for &pc in &partition_class_list { - let ccn = pc as usize; - for _ in 0..class_dimensions_list[ccn] { - let x = ss.read_bits(rangebits)?; - os.write_bits(x, rangebits); - } - } - } - - let residue_count_less1 = ss.read_bits(6)?; - let residue_count = residue_count_less1 + 1; - os.write_bits(residue_count_less1, 6); - - for _ in 0..residue_count { - let residue_type = ss.read_bits(2)?; - os.write_bits(residue_type, 16); - - if residue_type > 2 { - return Err("Invalid residue type".into()); - } - - let begin = ss.read_bits(24)?; - let end = ss.read_bits(24)?; - let partition_size_less1 = ss.read_bits(24)?; - let classifications_less1 = ss.read_bits(6)?; - let classbook = ss.read_bits(8)?; - let classifications = classifications_less1 + 1; - - os.write_bits(begin, 24); - os.write_bits(end, 24); - os.write_bits(partition_size_less1, 24); - os.write_bits(classifications_less1, 6); - os.write_bits(classbook, 8); - - if classbook >= codebook_count { - return Err("Invalid residue classbook".into()); - } - - let mut cascade = Vec::new(); - for _ in 0..classifications { - let low_bits = ss.read_bits(3)?; - os.write_bits(low_bits, 3); - - let bitflag = ss.read_bits(1)?; - os.write_bits(bitflag, 1); - - let mut high_bits = 0u32; - if bitflag != 0 { - high_bits = ss.read_bits(5)?; - os.write_bits(high_bits, 5); - } - cascade.push(high_bits * 8 + low_bits); - } - - for &cas in &cascade { - for k in 0..8u32 { - if cas & (1 << k) != 0 { - let book = ss.read_bits(8)?; - os.write_bits(book, 8); - if book >= codebook_count { - return Err("Invalid residue book".into()); - } - } - } - } - } - - let mapping_count_less1 = ss.read_bits(6)?; - let mapping_count = mapping_count_less1 + 1; - os.write_bits(mapping_count_less1, 6); - - for _ in 0..mapping_count { - os.write_bits(0, 16); // mapping type 0 - - let submaps_flag = ss.read_bits(1)?; - os.write_bits(submaps_flag, 1); - - let mut submaps = 1u32; - if submaps_flag != 0 { - let sl = ss.read_bits(4)?; - submaps = sl + 1; - os.write_bits(sl, 4); - } - - let square_polar = ss.read_bits(1)?; - os.write_bits(square_polar, 1); - - if square_polar != 0 { - let coupling_less1 = ss.read_bits(8)?; - let coupling_steps = coupling_less1 + 1; - os.write_bits(coupling_less1, 8); - - let channel_bits = ilog(self.channels - 1); - for _ in 0..coupling_steps { - let magnitude = ss.read_bits(channel_bits)?; - let angle = ss.read_bits(channel_bits)?; - os.write_bits(magnitude, channel_bits); - os.write_bits(angle, channel_bits); - } - } - - let reserved = ss.read_bits(2)?; - os.write_bits(reserved, 2); - if reserved != 0 { - return Err("Mapping reserved field nonzero".into()); - } - - if submaps > 1 { - for _ in 0..self.channels { - let mux = ss.read_bits(4)?; - os.write_bits(mux, 4); - } - } - - for _ in 0..submaps { - let time_config = ss.read_bits(8)?; - os.write_bits(time_config, 8); - - let floor_number = ss.read_bits(8)?; - os.write_bits(floor_number, 8); - if floor_number >= floor_count { - return Err("Invalid floor mapping".into()); - } - - let residue_number = ss.read_bits(8)?; - os.write_bits(residue_number, 8); - if residue_number >= residue_count { - return Err("Invalid residue mapping".into()); - } - } - } - - let mode_count_less1 = ss.read_bits(6)?; - let mode_count = mode_count_less1 + 1; - os.write_bits(mode_count_less1, 6); - - let mut mode_blockflag = Vec::new(); - let mode_bits = ilog(mode_count - 1); - - for _ in 0..mode_count { - let block_flag = ss.read_bits(1)?; - os.write_bits(block_flag, 1); - mode_blockflag.push(block_flag != 0); - - os.write_bits(0, 16); // window type - os.write_bits(0, 16); // transform type - - let mapping = ss.read_bits(8)?; - os.write_bits(mapping, 8); - if mapping >= mapping_count { - return Err("Invalid mode mapping".into()); - } - } - - Ok((mode_blockflag, mode_bits)) - } - - fn generate_ogg_header_with_triad(&self, os: &mut BitOggWriter) -> Result<(), String> { - let mut offset = self.data_offset + self.setup_packet_offset as usize; - - // Identification packet (old 8-byte header) - let info_packet = PacketHeader::read_old(self.data, offset)?; - if info_packet.granule != 0 { - return Err("Information packet granule != 0".into()); - } - - let info_payload = info_packet.payload_offset(offset); - if self.data[info_payload] != 1 { - return Err("Wrong type for information packet".into()); - } - - for i in 0..info_packet.size { - os.write_bits(self.data[info_payload + i] as u32, 8); - } - os.flush_page(false, false); - - offset = info_packet.next_offset(offset); - - let comment_packet = PacketHeader::read_old(self.data, offset)?; - if comment_packet.granule != 0 { - return Err("Comment packet granule != 0".into()); - } - - let comment_payload = comment_packet.payload_offset(offset); - if self.data[comment_payload] != 3 { - return Err("Wrong type for comment packet".into()); - } - - for i in 0..comment_packet.size { - os.write_bits(self.data[comment_payload + i] as u32, 8); - } - os.flush_page(false, false); - - offset = comment_packet.next_offset(offset); - - let setup_packet = PacketHeader::read_old(self.data, offset)?; - if setup_packet.granule != 0 { - return Err("Setup packet granule != 0".into()); - } - - let setup_payload = setup_packet.payload_offset(offset); - let mut ss = BitReader::new(self.data, setup_payload); - - let setup_type = ss.read_bits(8)?; - if setup_type != 5 { - return Err("Wrong type for setup packet".into()); - } - os.write_bits(setup_type, 8); - - // "vorbis" - for _ in 0..6 { - os.write_bits(ss.read_bits(8)?, 8); - } - - let codebook_count_less1 = ss.read_bits(8)?; - let codebook_count = codebook_count_less1 + 1; - os.write_bits(codebook_count_less1, 8); - - for _ in 0..codebook_count { - CodebookLibrary::copy_codebook(&mut ss, os)?; - } - - while ss.total_bits_read() < setup_packet.size * 8 { - os.write_bits(ss.read_bits(1)?, 1); - } - - os.flush_page(false, false); - Ok(()) - } - - fn write_vorbis_packet_header(&self, os: &mut BitOggWriter, packet_type: u8) { - os.write_bits(packet_type as u32, 8); - for &b in b"vorbis" { - os.write_bits(b as u32, 8); - } - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -pub fn decode_wem(wem_data: &[u8]) -> Result { - let codebook_lib = CodebookLibrary::load(CODEBOOK_DATA)?; - let converter = WwiseRiffVorbis::parse(wem_data, &codebook_lib)?; - - if converter.is_wav { - let wav = converter.generate_wav(); - Ok(DecodedAudio { - sample_rate: Some(converter.sample_rate), - data: wav, - format: "wav".into(), - }) - } else { - let ogg = converter.generate_ogg()?; - Ok(DecodedAudio { - sample_rate: Some(converter.sample_rate), - data: ogg, - format: "ogg".into(), - }) - } -} diff --git a/src-tauri/crates/quartz-lib/src/audio/wpk.rs b/src-tauri/crates/quartz-lib/src/audio/wpk.rs deleted file mode 100644 index 3010933e..00000000 --- a/src-tauri/crates/quartz-lib/src/audio/wpk.rs +++ /dev/null @@ -1,309 +0,0 @@ -use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize}; -use std::io::{Cursor, Read, Write}; - -use super::bnk::{AudioEntry, AudioEntryInfo}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WpkInfo { - pub format: String, - pub version: u32, - pub entry_count: usize, - pub entries: Vec, -} - -#[derive(Debug, Clone)] -struct WpkEntry { - id: u32, - data_offset: u32, - data_length: u32, -} - -#[derive(Debug)] -pub struct WpkFile { - pub version: u32, - entries: Vec, -} - -impl WpkFile { - pub fn parse(data: &[u8]) -> Result { - let mut cursor = Cursor::new(data); - - let mut magic = [0u8; 4]; - cursor - .read_exact(&mut magic) - .map_err(|e| format!("Failed to read WPK magic: {e}"))?; - if &magic != b"r3d2" { - return Err("Not a valid WPK file — missing r3d2 header".into()); - } - - let version = cursor - .read_u32::() - .map_err(|e| format!("Failed to read WPK version: {e}"))?; - let file_count = cursor - .read_u32::() - .map_err(|e| format!("Failed to read WPK file count: {e}"))?; - - let mut offsets = Vec::with_capacity(file_count as usize); - for _ in 0..file_count { - offsets.push( - cursor - .read_u32::() - .map_err(|e| format!("Failed to read WPK offset: {e}"))?, - ); - } - - let mut entries = Vec::new(); - - for &offset in &offsets { - if offset == 0 { - continue; - } - - cursor.set_position(offset as u64); - - let data_offset = cursor - .read_u32::() - .map_err(|e| format!("Failed to read entry data offset: {e}"))?; - let data_length = cursor - .read_u32::() - .map_err(|e| format!("Failed to read entry data length: {e}"))?; - let filename_length = cursor - .read_u32::() - .map_err(|e| format!("Failed to read filename length: {e}"))?; - - // Read UTF-16LE filename - let mut filename = String::new(); - for _ in 0..filename_length { - let lo = cursor.read_u8().unwrap_or(0); - let _hi = cursor.read_u8().unwrap_or(0); - filename.push(lo as char); - } - - let id: u32 = filename.trim_end_matches(".wem").parse().unwrap_or(0); - - entries.push(WpkEntry { - id, - data_offset, - data_length, - }); - } - - entries.sort_by_key(|e| e.id); - - Ok(WpkFile { version, entries }) - } - - pub fn info(&self) -> WpkInfo { - WpkInfo { - format: "wpk".into(), - version: self.version, - entry_count: self.entries.len(), - entries: self - .entries - .iter() - .map(|e| AudioEntryInfo { - id: e.id, - size: e.data_length, - }) - .collect(), - } - } - - pub fn read_entry_data<'a>(&self, data: &'a [u8], file_id: u32) -> Result<&'a [u8], String> { - let entry = self - .entries - .iter() - .find(|e| e.id == file_id) - .ok_or_else(|| format!("Audio entry {file_id} not found in WPK"))?; - - let start = entry.data_offset as usize; - let end = start + entry.data_length as usize; - - if end > data.len() { - return Err(format!( - "Audio entry {file_id} data out of bounds ({}..{} > {})", - start, - end, - data.len() - )); - } - - Ok(&data[start..end]) - } - - pub fn read_all_entries(&self, data: &[u8]) -> Result, String> { - let mut result = Vec::with_capacity(self.entries.len()); - for entry in &self.entries { - let start = entry.data_offset as usize; - let end = start + entry.data_length as usize; - if end > data.len() { - return Err(format!("Audio entry {} data out of bounds", entry.id)); - } - result.push(AudioEntry { - id: entry.id, - data: data[start..end].to_vec(), - }); - } - Ok(result) - } -} - -pub fn parse_wpk_metadata(data: &[u8]) -> Result { - let wpk = WpkFile::parse(data)?; - Ok(wpk.info()) -} - -pub fn read_wpk_entry(data: &[u8], file_id: u32) -> Result, String> { - let wpk = WpkFile::parse(data)?; - Ok(wpk.read_entry_data(data, file_id)?.to_vec()) -} - -// --------------------------------------------------------------------------- -// Writer -// --------------------------------------------------------------------------- - -const WPK_ALIGNMENT: usize = 8; - -fn align_up(val: usize, alignment: usize) -> usize { - let rem = val % alignment; - if rem == 0 { - val - } else { - val + (alignment - rem) - } -} - -pub fn write_wpk(entries: &[AudioEntry]) -> Vec { - let count = entries.len(); - - // Header: "r3d2" (4) + version (4) + file_count (4) + offset_table (count * 4) - let header_size = 12 + count * 4; - let aligned_header = align_up(header_size, WPK_ALIGNMENT); - - struct EntryLayout { - info_pos: usize, - filename: String, - } - let mut layouts = Vec::with_capacity(count); - let mut current_pos = aligned_header; - - for entry in entries { - let filename = format!("{}.wem", entry.id); - let info_size = 12 + filename.len() * 2; // 3 u32s + UTF-16LE chars - let aligned_info = align_up(info_size, WPK_ALIGNMENT); - layouts.push(EntryLayout { - info_pos: current_pos, - filename, - }); - current_pos += aligned_info; - } - - let mut data_offsets = Vec::with_capacity(count); - for entry in entries { - data_offsets.push(current_pos); - current_pos += entry.data.len(); - current_pos = align_up(current_pos, WPK_ALIGNMENT); - } - - let total_size = current_pos; - let mut buf = vec![0u8; total_size]; - let mut w = Cursor::new(&mut buf[..]); - - w.write_all(b"r3d2").unwrap(); - w.write_u32::(1).unwrap(); // version - w.write_u32::(count as u32).unwrap(); - - for layout in &layouts { - w.write_u32::(layout.info_pos as u32).unwrap(); - } - - for (i, layout) in layouts.iter().enumerate() { - w.set_position(layout.info_pos as u64); - w.write_u32::(data_offsets[i] as u32).unwrap(); - w.write_u32::(entries[i].data.len() as u32) - .unwrap(); - w.write_u32::(layout.filename.len() as u32) - .unwrap(); - - for ch in layout.filename.bytes() { - w.write_u8(ch).unwrap(); - w.write_u8(0).unwrap(); - } - } - - for (i, entry) in entries.iter().enumerate() { - buf[data_offsets[i]..data_offsets[i] + entry.data.len()].copy_from_slice(&entry.data); - } - - buf -} - -pub fn replace_wpk_entry(data: &[u8], file_id: u32, new_wem: &[u8]) -> Result, String> { - let wpk = WpkFile::parse(data)?; - let mut entries = wpk.read_all_entries(data)?; - - let entry = entries - .iter_mut() - .find(|e| e.id == file_id) - .ok_or_else(|| format!("Entry {file_id} not found"))?; - entry.data = new_wem.to_vec(); - - Ok(write_wpk(&entries)) -} - -pub fn remove_wpk_entry(data: &[u8], file_id: u32) -> Result, String> { - let wpk = WpkFile::parse(data)?; - let mut entries = wpk.read_all_entries(data)?; - let before = entries.len(); - entries.retain(|e| e.id != file_id); - if entries.len() == before { - return Err(format!("Entry {file_id} not found")); - } - Ok(write_wpk(&entries)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_wpk() -> Vec { - let e1 = AudioEntry { - id: 100, - data: vec![0xAA; 32], - }; - let e2 = AudioEntry { - id: 200, - data: vec![0xBB; 16], - }; - write_wpk(&[e1, e2]) - } - - #[test] - fn roundtrip_write_parse() { - let wpk_data = make_test_wpk(); - let info = parse_wpk_metadata(&wpk_data).unwrap(); - assert_eq!(info.format, "wpk"); - assert_eq!(info.entry_count, 2); - assert_eq!(info.entries[0].id, 100); - assert_eq!(info.entries[0].size, 32); - assert_eq!(info.entries[1].id, 200); - assert_eq!(info.entries[1].size, 16); - } - - #[test] - fn read_entry_data() { - let wpk_data = make_test_wpk(); - let entry_data = read_wpk_entry(&wpk_data, 100).unwrap(); - assert_eq!(entry_data.len(), 32); - assert!(entry_data.iter().all(|&b| b == 0xAA)); - } - - #[test] - fn replace_entry() { - let wpk_data = make_test_wpk(); - let new_wpk = replace_wpk_entry(&wpk_data, 100, &[0xCC; 64]).unwrap(); - let info = parse_wpk_metadata(&new_wpk).unwrap(); - assert_eq!(info.entries[0].size, 64); - } -} diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 020a550f..f508f2f9 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,83 +1,44 @@ /* Sound Banks (BnkExtract) backend. -Pure-Rust BNK/WPK parsing, HIRC event mapping and WEM->OGG/WAV decoding live in -quartz-lib::audio. WEM encoding and MP3 decoding need external tools -(WwiseConsole.exe + vgmstream-cli.exe) fetched from the tarngaina/LtMAO repo into -%APPDATA%/RitoShark/AudioTools, mirroring the original Electron handler. */ +Everything here is in-process. BNK/WPK containers, WEM decoding and Wwise Vorbis encoding come +from ritoshark::audio; user-supplied mp3/flac/ogg/m4a is decoded by quartz_lib::audio::decode. + +This used to download WwiseConsole.exe and vgmstream-cli.exe from a third-party repo into +%APPDATA%/RitoShark/AudioTools and shell out to them. The Rust encoder replaced the only thing +that genuinely needed an external toolchain, so the download is gone and any copy a previous +version left behind is removed on startup. */ use base64::Engine; +use quartz_lib::audio::bank; +use quartz_lib::audio::decode; use quartz_lib::audio::tree::{self, LoadBanksResult}; -use quartz_lib::audio::wem; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Emitter}; // --------------------------------------------------------------------------- -// Tool paths (shared RitoShark appdata dir) +// Legacy toolchain cleanup // --------------------------------------------------------------------------- -fn audio_tools_root() -> Result { - let appdata = std::env::var("APPDATA").map_err(|_| "APPDATA not set".to_string())?; - Ok(PathBuf::from(appdata).join("RitoShark").join("AudioTools")) -} - -fn wwise_console_exe() -> Result { - Ok(audio_tools_root()? - .join("Wwise") - .join("WwiseApp") - .join("Authoring") - .join("x64") - .join("Release") - .join("bin") - .join("WwiseConsole.exe")) -} - -fn wwise_wproj() -> Result { - Ok(audio_tools_root()? - .join("Wwise") - .join("WwiseLeagueProjects") - .join("WWiseLeagueProjects.wproj")) -} - -fn vgmstream_exe() -> Result { - Ok(audio_tools_root()? - .join("Decoders") - .join("vgmstream-cli.exe")) +fn audio_tools_root() -> Option { + std::env::var("APPDATA") + .ok() + .map(|appdata| PathBuf::from(appdata).join("RitoShark").join("AudioTools")) } -fn wwise_temp_dir() -> Result { - Ok(audio_tools_root()?.join("Temp")) -} +/** Deletes the Wwise/vgmstream toolchain older versions downloaded. -/// Spawn a child process hidden (no console window on Windows) and wait for it. -fn run_hidden(exe: &Path, args: &[&str], cwd: Option<&Path>) -> Result<(), String> { - use std::process::Command; - let mut cmd = Command::new(exe); - cmd.args(args); - if let Some(dir) = cwd { - cmd.current_dir(dir); - } - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); +Several hundred megabytes of third-party binaries that nothing reads any more. Called once at +startup; a missing directory is the normal case and not an error. */ +pub fn remove_legacy_audio_tools() { + let Some(root) = audio_tools_root() else { + return; + }; + if !root.exists() { + return; } - let output = cmd - .output() - .map_err(|e| format!("Failed to launch {}: {e}", exe.display()))?; - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "{} exited with {}: {}", - exe.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("process"), - output.status, - stderr.trim() - )) + match std::fs::remove_dir_all(&root) { + Ok(()) => tracing::info!("removed the legacy audio toolchain at {}", root.display()), + Err(e) => tracing::warn!("could not remove {}: {e}", root.display()), } } @@ -100,56 +61,34 @@ pub async fn bnk_load_banks(args: LoadBanksArgs) -> Result, want_wav: bool) -> Result, String> { - let decoded = wem::decode_wem(&data)?; - if want_wav && decoded.format != "wav" { - // The decoder returns OGG for Vorbis WEMs; the UI only needs a playable - // container, and an OGG is acceptable where WAV was requested for non-PCM - // sources. Hand back whatever the decoder produced. - return Ok(decoded.data); - } - Ok(decoded.data) -} - -/// Decode raw WEM bytes to a playable container (OGG or WAV). +/// Decode raw WEM bytes to a playable container (OGG for Vorbis, WAV for PCM). #[tauri::command] pub async fn bnk_wem_to_ogg(data: Vec) -> Result, String> { - tokio::task::spawn_blocking(move || decode_to(data, false)) + tokio::task::spawn_blocking(move || bank::decode_wem(&data).map(|d| d.data)) .await .map_err(|e| format!("wem decode task failed: {e}"))? } -/// Decode raw WEM bytes to WAV/OGG PCM for extraction. +/// Decode raw WEM bytes all the way to a WAV, whatever the source codec was. #[tauri::command] pub async fn bnk_wem_to_wav(data: Vec) -> Result, String> { - tokio::task::spawn_blocking(move || decode_to(data, true)) + tokio::task::spawn_blocking(move || bank::wem_to_pcm(&data).map(|pcm| bank::pcm_to_wav(&pcm))) .await .map_err(|e| format!("wem decode task failed: {e}"))? } -/// Decode a WEM, then transcode to MP3 via vgmstream + the system tools. When the -/// tools are missing we fall back to the decoded OGG/WAV bytes so extraction still -/// produces a file. +/// There is no MP3 encoder here, so this yields the decoded container instead. #[tauri::command] pub async fn bnk_wem_to_mp3(data: Vec, bitrate: u32) -> Result, String> { let _ = bitrate; - tokio::task::spawn_blocking(move || decode_to(data, false)) - .await - .map_err(|e| format!("wem decode task failed: {e}"))? -} - -/// The packed codebook bundled with the decoder. Returned so the frontend can keep -/// its loadCodebook() contract, though the Rust decoder embeds its own copy. -#[tauri::command] -pub async fn bnk_load_codebook() -> Result, String> { - Ok(wem::codebook_bytes().to_vec()) + bnk_wem_to_ogg(data).await } // --------------------------------------------------------------------------- @@ -164,6 +103,9 @@ pub struct ExtractNode { pub audio_data: Option, #[serde(default)] pub children: Option>, + /// Set on a root node — the container the tree was loaded from. + #[serde(default)] + pub original_path: Option, } #[derive(Debug, Deserialize)] @@ -178,6 +120,8 @@ pub struct ExtractAudio { pub struct ExtractArgs { pub nodes: Vec, pub formats: Vec, + /// Part of the frontend's payload; unread because there is no MP3 encoder. + #[allow(dead_code)] pub mp3_bitrate: u32, pub out_dir: String, } @@ -205,32 +149,20 @@ fn write_node_formats( .map_err(|e| format!("write wem failed: {e}"))?; *count += 1; } - if formats.iter().any(|f| f == "ogg") { - if let Ok(decoded) = wem::decode_wem(&audio.data) { - let ext = if decoded.format == "wav" { - "wav" - } else { - "ogg" - }; + // ogg and mp3 both land as the decoded container; only wav is converted. + if formats.iter().any(|f| f == "ogg" || f == "mp3") { + if let Ok(decoded) = bank::decode_wem(&audio.data) { + let ext = if decoded.format == "wav" { "wav" } else { "ogg" }; let _ = std::fs::write(cur_dir.join(format!("{base}.{ext}")), &decoded.data); *count += 1; } } if formats.iter().any(|f| f == "wav") { - if let Ok(decoded) = wem::decode_wem(&audio.data) { - let _ = std::fs::write(cur_dir.join(format!("{base}.wav")), &decoded.data); - *count += 1; - } - } - if formats.iter().any(|f| f == "mp3") { - // No native MP3 encoder; emit the decoded container so a file still lands. - if let Ok(decoded) = wem::decode_wem(&audio.data) { - let ext = if decoded.format == "wav" { - "wav" - } else { - "ogg" - }; - let _ = std::fs::write(cur_dir.join(format!("{base}.{ext}")), &decoded.data); + if let Ok(pcm) = bank::wem_to_pcm(&audio.data) { + let _ = std::fs::write( + cur_dir.join(format!("{base}.wav")), + bank::pcm_to_wav(&pcm), + ); *count += 1; } } @@ -278,10 +210,13 @@ pub struct SaveBankArgs { pub out_path: String, } -/// Collect every audio leaf under a node into (id, data) pairs. -fn collect_audio(node: &ExtractNode, out: &mut Vec<(u32, Vec)>) { +/// Collect every audio leaf under a node into entries. +fn collect_audio(node: &ExtractNode, out: &mut Vec) { if let Some(audio) = &node.audio_data { - out.push((audio.id, audio.data.clone())); + out.push(bank::AudioEntry { + id: audio.id, + data: audio.data.clone(), + }); } if let Some(children) = &node.children { for child in children { @@ -290,29 +225,29 @@ fn collect_audio(node: &ExtractNode, out: &mut Vec<(u32, Vec)>) { } } -/// Serialize a root node's audio back into a .bnk or .wpk container. +/** Write a root node's audio back into its container. + +The edit is applied to the bank the tree was loaded from rather than to a fresh one, so the header +revision, the bank id and the object hierarchy survive. Rebuilding from scratch — which is what +this did before — produced a bank the engine could not load however good the audio inside it was, +so a missing source is an error rather than a silent fallback. */ #[tauri::command] pub async fn bnk_save_bank(args: SaveBankArgs) -> Result<(), String> { - use quartz_lib::audio::bnk::{self, AudioEntry}; - use quartz_lib::audio::wpk; - tokio::task::spawn_blocking(move || { - let mut pairs: Vec<(u32, Vec)> = Vec::new(); - collect_audio(&args.root, &mut pairs); - pairs.sort_by_key(|p| p.0); - pairs.dedup_by_key(|p| p.0); - - let entries: Vec = pairs - .into_iter() - .map(|(id, data)| AudioEntry { id, data }) - .collect(); - - let lower = args.out_path.to_lowercase(); - let bytes = if lower.ends_with(".wpk") { - wpk::write_wpk(&entries) - } else { - bnk::write_bnk(&entries) - }; + let source = args + .root + .original_path + .as_deref() + .ok_or("This bank has no source file recorded, so it cannot be saved safely")?; + let original = std::fs::read(source) + .map_err(|e| format!("could not read the source bank '{source}': {e}"))?; + + let mut entries: Vec = Vec::new(); + collect_audio(&args.root, &mut entries); + entries.sort_by_key(|e| e.id); + entries.dedup_by_key(|e| e.id); + + let bytes = bank::save_with_entries(&original, &entries)?; std::fs::write(&args.out_path, bytes).map_err(|e| format!("write bank failed: {e}")) }) .await @@ -320,152 +255,19 @@ pub async fn bnk_save_bank(args: SaveBankArgs) -> Result<(), String> { } // --------------------------------------------------------------------------- -// Wwise / vgmstream tooling +// Conversion // --------------------------------------------------------------------------- -#[tauri::command] -pub async fn wwise_check() -> Result { - Ok(wwise_console_exe()?.exists()) -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallResult { - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Deserialize)] -struct GitTreeItem { - path: String, - #[serde(rename = "type")] - kind: String, -} - -#[derive(Debug, Deserialize)] -struct GitTree { - tree: Vec, -} - -/// Download the wiwawe (Wwise) + vgmstream tool files from tarngaina/LtMAO, -/// emitting `wwise:install-progress` events as it goes. -#[tauri::command] -pub async fn wwise_install(app: AppHandle) -> Result { - const REPO: &str = "tarngaina/LtMAO"; - const BRANCH: &str = "hai"; - let tree_api = format!("https://api.github.com/repos/{REPO}/git/trees/{BRANCH}?recursive=1"); - let raw_base = format!("https://raw.githubusercontent.com/{REPO}/{BRANCH}/"); - - let wanted_prefixes = ["res/wiwawe/", "res/tools/vgmstream/"]; - let dest_map: [(&str, PathBuf); 2] = [ - ("res/wiwawe/", audio_tools_root()?.join("Wwise")), - ("res/tools/vgmstream/", audio_tools_root()?.join("Decoders")), - ]; - - let progress = |msg: &str| { - let _ = app.emit("wwise:install-progress", msg.to_string()); - }; - - let result: Result<(), String> = async { - std::fs::create_dir_all(audio_tools_root()?).map_err(|e| e.to_string())?; - std::fs::create_dir_all(wwise_temp_dir()?).map_err(|e| e.to_string())?; - - let client = reqwest::Client::builder() - .user_agent("Quartz-App") - .build() - .map_err(|e| e.to_string())?; - - progress("Fetching file list from GitHub..."); - let tree: GitTree = client - .get(&tree_api) - .send() - .await - .map_err(|e| format!("GitHub tree request failed: {e}"))? - .json() - .await - .map_err(|e| format!("GitHub tree parse failed: {e}"))?; - - let files: Vec<&GitTreeItem> = tree - .tree - .iter() - .filter(|item| { - item.kind == "blob" && wanted_prefixes.iter().any(|p| item.path.starts_with(p)) - }) - .collect(); - - if files.is_empty() { - return Err("No files found — repo structure may have changed".into()); - } - - let total = files.len(); - progress(&format!("Installing audio tools (0 / {total} files)...")); - - let mut done = 0usize; - for item in files { - if item.path.contains("..") { - continue; - } - let mapping = dest_map.iter().find(|(p, _)| item.path.starts_with(p)); - let (prefix, dest) = match mapping { - Some(m) => m, - None => continue, - }; - let rel = &item.path[prefix.len()..]; - let dest_path = rel.split('/').fold(dest.clone(), |acc, seg| acc.join(seg)); - - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - - let bytes = client - .get(format!("{raw_base}{}", item.path)) - .send() - .await - .map_err(|e| format!("download failed for {}: {e}", item.path))? - .bytes() - .await - .map_err(|e| format!("download read failed for {}: {e}", item.path))?; - std::fs::write(&dest_path, &bytes) - .map_err(|e| format!("write failed for {}: {e}", dest_path.display()))?; - - done += 1; - progress(&format!( - "Installing audio tools ({done} / {total} files)..." - )); - } - - if !wwise_console_exe()?.exists() { - return Err( - "WwiseConsole.exe not found after install — repo structure may have changed." - .into(), - ); - } - - progress("Done!"); - Ok(()) - } - .await; - - match result { - Ok(()) => Ok(InstallResult { - success: true, - error: None, - }), - Err(e) => Ok(InstallResult { - success: false, - error: Some(e), - }), - } -} - -/// Convert a user wav/mp3/ogg file to .wem via vgmstream + WwiseConsole. Returns -/// the encoded WEM bytes. +/// Convert a user audio file on disk to a Wwise Vorbis WEM. #[tauri::command] pub async fn audio_convert_to_wem(input_path: String) -> Result, String> { - tokio::task::spawn_blocking(move || convert_to_wem_blocking(&input_path)) - .await - .map_err(|e| format!("convert task failed: {e}"))? + tokio::task::spawn_blocking(move || { + let data = std::fs::read(&input_path) + .map_err(|e| format!("could not read '{input_path}': {e}"))?; + bank::to_wem(&data, None) + }) + .await + .map_err(|e| format!("convert task failed: {e}"))? } #[derive(Debug, Deserialize)] @@ -483,297 +285,44 @@ pub struct BatchWemOutput { pub error: Option, } -/// Convert splitter WAV segments in one WwiseConsole invocation, matching the -/// Electron workflow without exposing temporary paths to the webview. +/// Convert splitter segments to WEM. One failure does not sink the batch. #[tauri::command] pub async fn audio_convert_wavs_to_wem( inputs: Vec, ) -> Result, String> { - tokio::task::spawn_blocking(move || convert_wavs_to_wem_blocking(inputs)) - .await - .map_err(|e| format!("batch convert task failed: {e}"))? -} - -fn xml_attribute(value: &str) -> String { - value - .replace('&', "&") - .replace('"', """) - .replace('<', "<") - .replace('>', ">") -} - -fn safe_audio_stem(name: &str) -> String { - let raw = Path::new(name) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("segment"); - let safe: String = raw - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '_' || character == '-' { - character - } else { - '_' - } - }) - .collect(); - if safe.is_empty() { - "segment".to_string() - } else { - safe - } -} - -fn convert_wavs_to_wem_blocking(inputs: Vec) -> Result, String> { - if inputs.is_empty() { - return Ok(Vec::new()); - } - - let console = wwise_console_exe()?; - if !console.exists() { - return Err("Wwise tools not installed".into()); - } - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|error| error.to_string())?; - let uid = unique_id(); - let wsources = temp.join(format!("split_batch_{uid}.wsources")); - - let mut jobs: Vec<(String, String, PathBuf)> = Vec::with_capacity(inputs.len()); - for (index, input) in inputs.into_iter().enumerate() { - let destination = format!("split_{uid}_{index}_{}", safe_audio_stem(&input.name)); - let wav_path = temp.join(format!("{destination}.wav")); - if let Err(error) = std::fs::write(&wav_path, input.data) { - for (_, _, path) in &jobs { - let _ = std::fs::remove_file(path); - } - return Err(format!("write splitter wav failed: {error}")); - } - jobs.push((input.name, destination, wav_path)); - } - - let mut xml = format!( - "\n\n", - xml_attribute(&temp.to_string_lossy()) - ); - for (_, destination, wav_path) in &jobs { - xml.push_str(&format!( - " \n", - xml_attribute(&wav_path.to_string_lossy()), - xml_attribute(destination), - )); - } - xml.push_str(""); - std::fs::write(&wsources, xml).map_err(|error| error.to_string())?; - - let wproj = wwise_wproj()?; - let conversion = run_hidden( - &console, - &[ - "convert-external-source", - &wproj.to_string_lossy(), - "--source-file", - &wsources.to_string_lossy(), - "--output", - &temp.to_string_lossy(), - "--platform", - "Windows", - ], - console.parent(), - ); - - let _ = std::fs::remove_file(&wsources); - for (_, _, wav_path) in &jobs { - let _ = std::fs::remove_file(wav_path); - } - conversion?; - - let outputs = jobs - .into_iter() - .map(|(name, destination, _)| { - let candidates = [ - temp.join("Windows").join(format!("{destination}.wem")), - temp.join(format!("{destination}.wem")), - ]; - let wem_path = candidates.iter().find(|path| path.exists()); - let result = match wem_path { - Some(path) => std::fs::read(path) - .map(|data| base64::engine::general_purpose::STANDARD.encode(data)) - .map_err(|error| format!("read converted WEM failed: {error}")), - None => Err("Wwise did not produce a WEM file".to_string()), - }; - if let Some(path) = wem_path { - let _ = std::fs::remove_file(path); - } - match result { - Ok(data_base64) => BatchWemOutput { - name, - data_base64: Some(data_base64), + tokio::task::spawn_blocking(move || { + inputs + .into_iter() + .map(|input| match bank::to_wem(&input.data, None) { + Ok(wem) => BatchWemOutput { + name: input.name, + data_base64: Some(base64::engine::general_purpose::STANDARD.encode(wem)), error: None, }, Err(error) => BatchWemOutput { - name, + name: input.name, data_base64: None, error: Some(error), }, - } - }) - .collect(); - - Ok(outputs) -} - -fn unique_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("{nanos}") -} - -fn convert_to_wem_blocking(input_path: &str) -> Result, String> { - let console = wwise_console_exe()?; - if !console.exists() { - return Err("Wwise tools not installed".into()); - } - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|e| e.to_string())?; - - let input = Path::new(input_path); - let ext = input - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let base = input - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("audio") - .to_string(); - let uid = unique_id(); - let dest = format!("{base}_{uid}"); - - let mut wav_path = input.to_path_buf(); - let mut temp_files: Vec = Vec::new(); - - // MP3/OGG -> WAV via vgmstream. - if ext == "mp3" || ext == "ogg" { - let vgm = vgmstream_exe()?; - if !vgm.exists() { - return Err("vgmstream decoder not installed".into()); - } - let out_wav = temp.join(format!("{dest}.wav")); - run_hidden( - &vgm, - &["-o", &out_wav.to_string_lossy(), input_path], - vgm.parent(), - )?; - temp_files.push(out_wav.clone()); - wav_path = out_wav; - } - - // Normalize to signed 16-bit PCM so WwiseConsole always reads it. - let raw = std::fs::read(&wav_path).map_err(|e| format!("read wav failed: {e}"))?; - if let Some(norm) = normalize_wav_to_s16(&raw) { - let norm_path = temp.join(format!("{dest}_norm.wav")); - std::fs::write(&norm_path, &norm).map_err(|e| e.to_string())?; - temp_files.push(norm_path.clone()); - wav_path = norm_path; - } - - // Build .wsources and run WwiseConsole. - let wsources = temp.join(format!("{dest}.wsources")); - let xml = format!( - "\n\n \n", - root = temp.to_string_lossy(), - src = wav_path.to_string_lossy(), - dest = dest, - ); - std::fs::write(&wsources, xml).map_err(|e| e.to_string())?; - - let wproj = wwise_wproj()?; - run_hidden( - &console, - &[ - "convert-external-source", - &wproj.to_string_lossy(), - "--source-file", - &wsources.to_string_lossy(), - "--output", - &temp.to_string_lossy(), - "--platform", - "Windows", - ], - console.parent(), - )?; - - let candidates = [ - temp.join("Windows").join(format!("{dest}.wem")), - temp.join(format!("{dest}.wem")), - ]; - let wem_path = candidates.iter().find(|p| p.exists()); - - let result = match wem_path { - Some(p) => std::fs::read(p).map_err(|e| format!("read wem failed: {e}")), - None => Err("Conversion succeeded but .wem output not found".into()), - }; - - let _ = std::fs::remove_file(&wsources); - for f in &temp_files { - let _ = std::fs::remove_file(f); - } - if let Some(p) = wem_path { - let _ = std::fs::remove_file(p); - } - - result + }) + .collect() + }) + .await + .map_err(|e| format!("batch convert task failed: {e}")) } -/// Decode a WEM/MP3/OGG to WAV bytes via the native decoder or vgmstream. -/// The base64 result avoids leaving splitter temp files behind and is much -/// smaller on the IPC boundary than a JSON array containing every byte. +/// Decode a WEM, MP3, OGG, FLAC or WAV to WAV bytes, base64 for the IPC boundary. #[tauri::command] pub async fn audio_decode_to_wav(data: Vec) -> Result { tokio::task::spawn_blocking(move || { - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|e| e.to_string())?; - let uid = unique_id(); - - // Try the native decoder first (handles WEM directly). - if let Ok(decoded) = wem::decode_wem(&data) { - if decoded.format == "wav" { - return Ok(base64::engine::general_purpose::STANDARD.encode(decoded.data)); - } - } - - // Fall back to vgmstream on a temp input file. - let vgm = vgmstream_exe()?; - if !vgm.exists() { - return Err("vgmstream decoder not installed".into()); - } - let in_path = temp.join(format!("split_in_{uid}.bin")); - std::fs::write(&in_path, &data).map_err(|e| e.to_string())?; - let out = temp.join(format!("split_{uid}.wav")); - let res = run_hidden( - &vgm, - &["-o", &out.to_string_lossy(), &in_path.to_string_lossy()], - vgm.parent(), - ); - let _ = std::fs::remove_file(&in_path); - if let Err(error) = res { - let _ = std::fs::remove_file(&out); - return Err(error); - } - let wav = std::fs::read(&out).map_err(|e| format!("read decoded wav failed: {e}")); - let _ = std::fs::remove_file(&out); - Ok(base64::engine::general_purpose::STANDARD.encode(wav?)) + let pcm = bank::wem_to_pcm(&data).or_else(|_| decode::decode_any(&data))?; + Ok(base64::engine::general_purpose::STANDARD.encode(bank::pcm_to_wav(&pcm))) }) .await .map_err(|e| format!("decode task failed: {e}"))? } -/// Write raw bytes to a path, creating parent directories. Used by the audio -/// splitter to save sliced WAV segments the frontend encodes in JS. +/// Write raw bytes to a path, creating parent directories. #[tauri::command] pub async fn audio_write_file(path: String, data: Vec) -> Result<(), String> { tokio::task::spawn_blocking(move || { @@ -786,85 +335,15 @@ pub async fn audio_write_file(path: String, data: Vec) -> Result<(), String> .map_err(|e| format!("write task failed: {e}"))? } -/// Amplify a WEM by gain_db: decode -> scale PCM -> re-encode through Wwise. +/// Amplify a WEM by gain_db, re-encoding into the codec it already used. #[tauri::command] pub async fn audio_amplify_wem(data: Vec, gain_db: f32) -> Result, String> { - tokio::task::spawn_blocking(move || { - let console = wwise_console_exe()?; - let vgm = vgmstream_exe()?; - if !console.exists() { - return Err("Wwise tools not installed".into()); - } - if !vgm.exists() { - return Err("vgmstream decoder not installed".into()); - } - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|e| e.to_string())?; - let uid = unique_id(); - let base = format!("gain_{uid}"); - - // WEM -> WAV via vgmstream. - let in_wem = temp.join(format!("{base}.wem")); - std::fs::write(&in_wem, &data).map_err(|e| e.to_string())?; - let wav_path = temp.join(format!("{base}.wav")); - let dec = run_hidden( - &vgm, - &["-o", &wav_path.to_string_lossy(), &in_wem.to_string_lossy()], - vgm.parent(), - ); - let _ = std::fs::remove_file(&in_wem); - dec?; - - // Amplify PCM in place. - let raw = std::fs::read(&wav_path).map_err(|e| e.to_string())?; - let amplified = amplify_wav(&raw, gain_db); - std::fs::write(&wav_path, &lified).map_err(|e| e.to_string())?; - - // WAV -> WEM via WwiseConsole. - let wsources = temp.join(format!("{base}.wsources")); - let xml = format!( - "\n\n \n", - root = temp.to_string_lossy(), - src = wav_path.to_string_lossy(), - dest = base, - ); - std::fs::write(&wsources, xml).map_err(|e| e.to_string())?; - let wproj = wwise_wproj()?; - run_hidden( - &console, - &[ - "convert-external-source", - &wproj.to_string_lossy(), - "--source-file", - &wsources.to_string_lossy(), - "--output", - &temp.to_string_lossy(), - "--platform", - "Windows", - ], - console.parent(), - )?; - - let candidates = [ - temp.join("Windows").join(format!("{base}.wem")), - temp.join(format!("{base}.wem")), - ]; - let wem_path = candidates.iter().find(|p| p.exists()); - let result = match wem_path { - Some(p) => std::fs::read(p).map_err(|e| e.to_string()), - None => Err("Output WEM not found after conversion".into()), - }; - let _ = std::fs::remove_file(&wav_path); - let _ = std::fs::remove_file(&wsources); - if let Some(p) = wem_path { - let _ = std::fs::remove_file(p); - } - result - }) - .await - .map_err(|e| format!("amplify task failed: {e}"))? + tokio::task::spawn_blocking(move || bank::amplify_wem(&data, gain_db)) + .await + .map_err(|e| format!("amplify task failed: {e}"))? } + // --------------------------------------------------------------------------- // Mod folder scan + game extraction // --------------------------------------------------------------------------- @@ -1548,164 +1027,3 @@ fn extract_banks_from_game_blocking(args: GameBanksArgs) -> Result u32 { - u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) -} - -fn read_u16_le(b: &[u8], o: usize) -> u16 { - u16::from_le_bytes([b[o], b[o + 1]]) -} - -/// Find the fmt + data chunks. Returns (audio_format, bits, channels, sample_rate, -/// data_start, data_size). -fn parse_wav(buf: &[u8]) -> Option<(u16, u16, u16, u32, usize, usize)> { - if buf.len() < 12 { - return None; - } - let mut pos = 12usize; - let mut audio_format = 1u16; - let mut channels = 1u16; - let mut sample_rate = 44100u32; - let mut bits = 16u16; - while pos + 8 <= buf.len() { - let id = &buf[pos..pos + 4]; - let size = read_u32_le(buf, pos + 4) as usize; - if id == b"fmt " && pos + 24 <= buf.len() { - audio_format = read_u16_le(buf, pos + 8); - channels = read_u16_le(buf, pos + 10); - sample_rate = read_u32_le(buf, pos + 12); - bits = read_u16_le(buf, pos + 22); - } else if id == b"data" { - return Some((audio_format, bits, channels, sample_rate, pos + 8, size)); - } - pos += 8 + if size % 2 != 0 { size + 1 } else { size }; - } - None -} - -/// Amplify a PCM/float WAV buffer by gain_db decibels in place. -fn amplify_wav(buf: &[u8], gain_db: f32) -> Vec { - let gain = 10f32.powf(gain_db / 20.0); - let mut out = buf.to_vec(); - let (audio_format, bits, _ch, _sr, data_start, data_size) = match parse_wav(buf) { - Some(v) => v, - None => return out, - }; - let end = (data_start + data_size).min(out.len()); - - if audio_format == 1 && bits == 16 { - let mut i = data_start; - while i + 1 < end { - let s = i16::from_le_bytes([out[i], out[i + 1]]) as f32 * gain; - let clamped = s.round().clamp(-32768.0, 32767.0) as i16; - out[i..i + 2].copy_from_slice(&clamped.to_le_bytes()); - i += 2; - } - } else if audio_format == 1 && bits == 24 { - let mut i = data_start; - while i + 2 < end { - let mut s = (out[i] as i32) | ((out[i + 1] as i32) << 8) | ((out[i + 2] as i32) << 16); - if s & 0x80_0000 != 0 { - s |= !0xFF_FFFF; - } - let v = ((s as f32 * gain).round()).clamp(-8_388_608.0, 8_388_607.0) as i32; - out[i] = (v & 0xFF) as u8; - out[i + 1] = ((v >> 8) & 0xFF) as u8; - out[i + 2] = ((v >> 16) & 0xFF) as u8; - i += 3; - } - } else if audio_format == 1 && bits == 32 { - let mut i = data_start; - while i + 3 < end { - let s = i32::from_le_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) as f32 * gain; - let v = s.round().clamp(i32::MIN as f32, i32::MAX as f32) as i32; - out[i..i + 4].copy_from_slice(&v.to_le_bytes()); - i += 4; - } - } else if audio_format == 3 && bits == 32 { - let mut i = data_start; - while i + 3 < end { - let s = f32::from_le_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) * gain; - out[i..i + 4].copy_from_slice(&s.clamp(-1.0, 1.0).to_le_bytes()); - i += 4; - } - } - out -} - -/// Convert any WAV to signed-16-bit PCM. Returns None if already S16 (no change -/// needed) so callers can skip the rewrite. -fn normalize_wav_to_s16(buf: &[u8]) -> Option> { - let (audio_format, bits, channels, sample_rate, data_start, data_size) = parse_wav(buf)?; - if audio_format == 1 && bits == 16 { - return None; - } - let data_end = (data_start + data_size).min(buf.len()); - let data = &buf[data_start..data_end]; - - let mut samples: Vec = Vec::new(); - if audio_format == 1 && bits == 8 { - for &b in data { - samples.push(((b as i16) - 128) << 8); - } - } else if audio_format == 1 && bits == 24 { - let n = data.len() / 3; - for i in 0..n { - let mut s = (data[i * 3] as i32) - | ((data[i * 3 + 1] as i32) << 8) - | ((data[i * 3 + 2] as i32) << 16); - if s & 0x80_0000 != 0 { - s |= !0xFF_FFFF; - } - samples.push((s >> 8) as i16); - } - } else if audio_format == 1 && bits == 32 { - let n = data.len() / 4; - for i in 0..n { - let s = i32::from_le_bytes([ - data[i * 4], - data[i * 4 + 1], - data[i * 4 + 2], - data[i * 4 + 3], - ]); - samples.push((s >> 16) as i16); - } - } else if audio_format == 3 && bits == 32 { - let n = data.len() / 4; - for i in 0..n { - let f = f32::from_le_bytes([ - data[i * 4], - data[i * 4 + 1], - data[i * 4 + 2], - data[i * 4 + 3], - ]); - samples.push((f.clamp(-1.0, 1.0) * 32767.0).round() as i16); - } - } else { - return None; - } - - let new_data_size = samples.len() * 2; - let mut out = Vec::with_capacity(44 + new_data_size); - out.extend_from_slice(b"RIFF"); - out.extend_from_slice(&((36 + new_data_size) as u32).to_le_bytes()); - out.extend_from_slice(b"WAVE"); - out.extend_from_slice(b"fmt "); - out.extend_from_slice(&16u32.to_le_bytes()); - out.extend_from_slice(&1u16.to_le_bytes()); - out.extend_from_slice(&channels.to_le_bytes()); - out.extend_from_slice(&sample_rate.to_le_bytes()); - out.extend_from_slice(&(sample_rate * channels as u32 * 2).to_le_bytes()); - out.extend_from_slice(&(channels * 2).to_le_bytes()); - out.extend_from_slice(&16u16.to_le_bytes()); - out.extend_from_slice(b"data"); - out.extend_from_slice(&(new_data_size as u32).to_le_bytes()); - for s in samples { - out.extend_from_slice(&s.to_le_bytes()); - } - Some(out) -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e157e58d..d9cb8737 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -78,6 +78,9 @@ fn main() { if let Err(e) = commands::context_menu::context_menu_refresh_if_enabled() { tracing::warn!("Failed to refresh Explorer context menu: {}", e); } + // Audio encoding is in-process now; drop the toolchain older + // versions downloaded into %APPDATA% and never cleaned up. + std::thread::spawn(commands::audio::remove_legacy_audio_tools); // Seed bundled wallpapers/cursors so themed presets have their images. if let Ok(resource_dir) = app.path().resource_dir() { commands::assets::seed_bundled_assets(&resource_dir); @@ -256,11 +259,8 @@ fn main() { commands::audio::bnk_wem_to_ogg, commands::audio::bnk_wem_to_wav, commands::audio::bnk_wem_to_mp3, - commands::audio::bnk_load_codebook, commands::audio::bnk_extract_nodes, commands::audio::bnk_save_bank, - commands::audio::wwise_check, - commands::audio::wwise_install, commands::audio::audio_convert_to_wem, commands::audio::audio_convert_wavs_to_wem, commands::audio::audio_decode_to_wav, diff --git a/src/pages/BnkExtract.tsx b/src/pages/BnkExtract.tsx index 8392255a..2a11f866 100644 --- a/src/pages/BnkExtract.tsx +++ b/src/pages/BnkExtract.tsx @@ -19,7 +19,6 @@ import AutoExtractDialog from './bnkextract/components/AutoExtractDialog'; import AudioSplitter from './bnkextract/components/AudioSplitter'; import BnkMainContent from './bnkextract/components/BnkMainContent'; import BnkSettingsModal from './bnkextract/components/BnkSettingsModal'; -import BnkInstallModal from './bnkextract/components/BnkInstallModal'; import BnkConvertOverlay from './bnkextract/components/BnkConvertOverlay'; import BnkGainModal from './bnkextract/components/BnkGainModal'; import BnkContextMenu from './bnkextract/components/BnkContextMenu'; @@ -32,12 +31,11 @@ import LoadFromGameModal from './port/components/modals/PortDonorFromGameModal'; import type { BanksConfirmArgs } from './port/components/modals/donor/types'; import { - loadBanks, wemToPlayable, extractNodes, saveBank, checkWwiseInstalled, installWwise, - getModFiles, extractBnkBanksFromGame, loadCodebook, pickDirectory, + loadBanks, wemToPlayable, extractNodes, saveBank, + getModFiles, extractBnkBanksFromGame, pickDirectory, convertToWem, convertWavsToWem, amplifyWem, silenceWem, readFileBytes, } from './bnkextract/utils/backend'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; import { useFileDrop, type FileDropPosition } from '@/lib/util/useFileDrop'; import { containerStyle, mainContentStyle, treeViewStyle, sidebarStyle, @@ -188,12 +186,8 @@ export function BnkExtract() { const [leftSortMode, setLeftSortMode] = useState('name-asc'); // ── Wwise conversion state ──────────────────────────────────────────────── - const [isWwiseInstalled, setIsWwiseInstalled] = useState(false); - const [showInstallModal, setShowInstallModal] = useState(false); const [showConvertOverlay, setShowConvertOverlay] = useState(false); const [convertStatus, setConvertStatus] = useState(''); - const [installProgress, setInstallProgress] = useState(''); - const [isInstalling, setIsInstalling] = useState(false); // ── History (undo/redo) ─────────────────────────────────────────────────── const [undoStack, setUndoStack] = useState([]); @@ -240,9 +234,7 @@ export function BnkExtract() { // Object URL of the clip currently loaded into `audioRef`, so it can be // revoked when playback stops or another clip replaces it. const playingUrlRef = useRef(null); - const codebookDataRef = useRef(null); - const pendingConversion = useRef<{ filePath: string; targetNodeId: string } | null>(null); const pendingGroupIds = useRef([]); @@ -294,8 +286,6 @@ export function BnkExtract() { // ── Codebook + Wwise availability (once) ────────────────────────────────── useEffect(() => { - void loadCodebook().then((cb) => { codebookDataRef.current = cb; }).catch(() => { }); - void checkWwiseInstalled().then(setIsWwiseInstalled).catch(() => { }); }, []); // ── Playback ────────────────────────────────────────────────────────────── @@ -325,7 +315,7 @@ export function BnkExtract() { const playable = browserPlayable(raw) ? raw // Decode the WEM to a playable OGG/WAV container in Rust. - : await wemToPlayable(raw, codebookDataRef.current); + : await wemToPlayable(raw); if (!playable || playable.length === 0) { setStatusMessage(`Cannot decode ${node.name} for playback`); return; @@ -577,7 +567,6 @@ export function BnkExtract() { const handleReplace = useCallback(async () => { if (!hasAudioSelection()) return; - if (!isWwiseInstalled) { setShowInstallModal(true); return; } const picked = await pickPath({ mode: 'file', filters: [{ name: 'Audio', extensions: ['wem', 'wav', 'ogg', 'mp3'] }], recentsKey: 'audio' }); if (typeof picked !== 'string') return; const targets = collectSelectedAudioNodes(); @@ -599,7 +588,7 @@ export function BnkExtract() { } finally { setShowConvertOverlay(false); } - }, [hasAudioSelection, isWwiseInstalled, collectSelectedAudioNodes, activePane, pushToHistory, applyAudioToNodes]); + }, [hasAudioSelection, collectSelectedAudioNodes, activePane, pushToHistory, applyAudioToNodes]); const handleMakeSilent = useCallback((options?: { pane?: Pane; nodeIds?: string[] }) => { const pane = options?.pane ?? activePane; @@ -741,7 +730,6 @@ export function BnkExtract() { const applyExternalFiles = useCallback(async (files: { path: string; name: string }[], targetId: string, pane: Pane) => { const file = files.find((f) => /\.(wem|wav|ogg|mp3)$/i.test(f.name)); if (!file) return; - if (!file.name.toLowerCase().endsWith('.wem') && !isWwiseInstalled) { setShowInstallModal(true); return; } setShowConvertOverlay(true); setConvertStatus(`Converting ${file.name}...`); try { @@ -757,7 +745,7 @@ export function BnkExtract() { } finally { setShowConvertOverlay(false); } - }, [isWwiseInstalled, pushToHistory, applyAudioToNodes]); + }, [pushToHistory, applyAudioToNodes]); /* External file→node drops are routed by the OS drag-drop listener (via cursor hit-test), so the DOM handler is a no-op. Internal node→node drags still flow @@ -810,8 +798,6 @@ export function BnkExtract() { const importReferenceFiles = useCallback(async (paths: string[]) => { const audio = paths.filter((p) => /\.(wem|wav|ogg|mp3)$/i.test(p)); if (audio.length === 0) return; - const nonWem = audio.some((p) => !p.toLowerCase().endsWith('.wem')); - if (nonWem && !isWwiseInstalled) { setShowInstallModal(true); return; } setShowConvertOverlay(true); const children: BnkNode[] = []; for (let i = 0; i < audio.length; i++) { @@ -839,7 +825,7 @@ export function BnkExtract() { setRightExpandedNodes((prev) => new Set(prev).add(group.id)); setActivePane('right'); setStatusMessage(`Imported ${children.length} reference file(s)`); - }, [isWwiseInstalled, pushToHistory]); + }, [pushToHistory]); /* The OS drag-drop listener imports the real paths; the DOM handler only clears the drag-over highlight. */ @@ -848,21 +834,6 @@ export function BnkExtract() { setRightPaneDragOver(false); }, []); - // ── Wwise install ───────────────────────────────────────────────────────── - const handleInstallWwise = useCallback(async () => { - setIsInstalling(true); - setInstallProgress('Installing Wwise tools...'); - const unlisten = await listen('wwise:install-progress', (e) => setInstallProgress(e.payload)); - try { - const res = await installWwise(); - if (res.success) { setIsWwiseInstalled(true); setShowInstallModal(false); setStatusMessage('Wwise tools installed'); } - else setInstallProgress(res.error || 'Install failed'); - } finally { - unlisten(); - setIsInstalling(false); - } - }, []); - // ── Gain ────────────────────────────────────────────────────────────────── /* Re-encode each targeted WEM with the chosen dB gain (decode → scale → Wwise). */ const handleApplyGain = useCallback(async () => { @@ -879,7 +850,6 @@ export function BnkExtract() { } const withData = targets.filter((n) => n.audioData?.data?.length); if (withData.length === 0) { setStatusMessage('No audio to amplify'); return; } - if (!isWwiseInstalled) { setShowInstallModal(true); return; } const db = parseFloat(gainDb); if (isNaN(db)) { setStatusMessage('Invalid gain value'); return; } @@ -910,17 +880,13 @@ export function BnkExtract() { } finally { setShowConvertOverlay(false); } - }, [gainDb, gainTargetPane, selectedNodes, rightSelectedNodes, treeData, rightTreeData, isWwiseInstalled, pushToHistory]); + }, [gainDb, gainTargetPane, selectedNodes, rightSelectedNodes, treeData, rightTreeData, pushToHistory]); // ── Splitter actions ────────────────────────────────────────────────────── const handleOpenInSplitter = useCallback(() => { const node = contextMenu?.node; const pane = contextMenu?.pane || activePane; handleCloseContextMenu(); - if (node?.audioData && !isWwiseInstalled) { - setShowInstallModal(true); - return; - } setSplitterInitialFile(node ? { nodeId: node.id, name: node.name, @@ -929,7 +895,7 @@ export function BnkExtract() { data: node.audioData?.data, } : null); setShowAudioSplitter(true); - }, [contextMenu, activePane, isWwiseInstalled, handleCloseContextMenu]); + }, [contextMenu, activePane, handleCloseContextMenu]); const handleSplitterReplace = useCallback((data: Uint8Array, nodeId: string, pane?: string) => { if (!data?.length || !nodeId) return; @@ -940,12 +906,8 @@ export function BnkExtract() { const handleSplitterExportSegments = useCallback(async (segments: SplitterSegment[]) => { if (segments.length === 0) return; - if (!isWwiseInstalled) { - throw new Error('Wwise tools are required to push segments to the reference pane'); - } - setShowConvertOverlay(true); - setConvertStatus(`Converting ${segments.length} segment(s) with Wwise...`); + setConvertStatus(`Converting ${segments.length} segment(s)...`); try { const timestamp = Date.now(); const converted = await convertWavsToWem(segments); @@ -1005,7 +967,7 @@ export function BnkExtract() { setShowConvertOverlay(false); setConvertStatus(''); } - }, [isWwiseInstalled, pushToHistory]); + }, [pushToHistory]); // ── Auto-extract / mod folder ───────────────────────────────────────────── /* Parse each scanned mod-file set into the left tree, then (if an output dir @@ -1347,14 +1309,6 @@ export function BnkExtract() { return ( - { setShowInstallModal(false); pendingConversion.current = null; }} - onInstall={handleInstallWwise} - /> - 0} onRemoveFromGroup={() => { pendingGroupIds.current = getContextTargetIds(); handleCloseContextMenu(); handleRemoveFromGroup(); }} showRemoveFromGroup={contextMenu?.pane === 'right' && !!contextMenu?.node?.id && isNodeInGroup(contextMenu.node.id, rightTreeData)} - isWwiseInstalled={isWwiseInstalled} /> void; showRemoveFromGroup: boolean; - isWwiseInstalled: boolean; } export default function BnkContextMenu({ @@ -39,7 +38,6 @@ export default function BnkContextMenu({ showAddToGroup, onRemoveFromGroup, showRemoveFromGroup, - isWwiseInstalled, }: Props) { return ( Make Silent , - + Adjust Volume... - {!isWwiseInstalled && needs tools} , - + Open in Audio Splitter... - {contextMenu?.node?.audioData && !isWwiseInstalled && needs tools} , , diff --git a/src/pages/bnkextract/components/BnkInstallModal.tsx b/src/pages/bnkextract/components/BnkInstallModal.tsx deleted file mode 100644 index 1eaf968b..00000000 --- a/src/pages/bnkextract/components/BnkInstallModal.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { LinearProgress } from '@mui/material'; - -interface Props { - open: boolean; - isInstalling: boolean; - installProgress: string; - onCancel: () => void; - onInstall: () => void; -} - -export default function BnkInstallModal({ - open, - isInstalling, - installProgress, - onCancel, - onInstall, -}: Props) { - if (!open) return null; - - return ( -
-
-
-

Audio Conversion Tools

-
- -
-

- Converting .wav / .mp3 / .ogg to WEM - requires the Wwise engine (~200 MB). Install it once to your AppData folder. -

- - {isInstalling && ( -
- - {installProgress} -
- )} -
- - {!isInstalling && ( -
- - -
- )} -
-
- ); -} diff --git a/src/pages/bnkextract/utils/backend.ts b/src/pages/bnkextract/utils/backend.ts index e99ee940..9fe14fcc 100644 --- a/src/pages/bnkextract/utils/backend.ts +++ b/src/pages/bnkextract/utils/backend.ts @@ -1,8 +1,8 @@ /* BNK/WPK parse + WEM decode/encode backend. - Pure-Rust parsing/decoding plus the external Wwise/vgmstream tooling live behind - the bnk_* / wwise_* / audio_* Tauri commands. These wrappers keep stable - signatures so the rest of the UI is unaffected. */ + Everything is in-process behind the bnk_* / audio_* Tauri commands: containers + and Wwise Vorbis coding come from ritoshark::audio, and user mp3/flac/ogg is + decoded by symphonia. There is no external toolchain to install any more. */ import { invoke } from '@tauri-apps/api/core'; import { pickPath } from '@/components/explorer'; @@ -39,6 +39,7 @@ interface WireNode { name: string; audioData?: { id: number; data: number[] } | null; children?: WireNode[]; + originalPath?: string; } function toWireNode(node: BnkNode): WireNode { const wire: WireNode = { name: node.name }; @@ -46,6 +47,10 @@ function toWireNode(node: BnkNode): WireNode { wire.audioData = { id: node.audioData.id, data: Array.from(toBytes(node.audioData.data)) }; } if (node.children) wire.children = node.children.map(toWireNode); + /* Saving edits the container the tree came from instead of rebuilding one, + so the header and object hierarchy survive. The backend needs its path. */ + const source = node.originalPath ?? node.wpkPath ?? node.bnkPath; + if (source) wire.originalPath = source; return wire; } @@ -73,10 +78,7 @@ export async function loadBanks(args: LoadBanksArgs): Promise { +export async function wemToPlayable(raw: Uint8Array): Promise { try { return toBytes(await invoke('bnk_wem_to_ogg', { data: Array.from(raw) })); } catch (e) { @@ -86,13 +88,13 @@ export async function wemToPlayable( } /* Per-format conversions used by the extract pipeline. */ -export async function wemToWav(raw: Uint8Array, _codebook: Uint8Array | null): Promise { +export async function wemToWav(raw: Uint8Array): Promise { return toBytes(await invoke('bnk_wem_to_wav', { data: Array.from(raw) })); } -export async function wemToOgg(raw: Uint8Array, _codebook: Uint8Array | null): Promise { +export async function wemToOgg(raw: Uint8Array): Promise { return toBytes(await invoke('bnk_wem_to_ogg', { data: Array.from(raw) })); } -export async function wemToMp3(raw: Uint8Array, _codebook: Uint8Array | null, bitrate: number): Promise { +export async function wemToMp3(raw: Uint8Array, bitrate: number): Promise { return toBytes(await invoke('bnk_wem_to_mp3', { data: Array.from(raw), bitrate })); } @@ -113,21 +115,7 @@ export async function saveBank(root: BnkNode, outPath: string): Promise { await invoke('bnk_save_bank', { args: { root: toWireNode(root), outPath } }); } -/* Wwise / vgmstream tooling availability + install. */ -export async function checkWwiseInstalled(): Promise { - try { - return await invoke('wwise_check'); - } catch { - return false; - } -} -export async function installWwise(): Promise<{ success: boolean; error?: string }> { - try { - return await invoke<{ success: boolean; error?: string }>('wwise_install'); - } catch (e) { - return { success: false, error: (e as Error).message }; - } -} +/* Encoding is in-process now — no external toolchain to check for or install. */ export async function convertToWem(inputPath: string): Promise { return toBytes(await invoke('audio_convert_to_wem', { inputPath })); } @@ -205,15 +193,6 @@ export function silenceWem(): Uint8Array { return bytes; } -/* Codebook used by the WEM decoder, loaded once at mount. */ -export async function loadCodebook(): Promise { - try { - return toBytes(await invoke('bnk_load_codebook')); - } catch { - return null; - } -} - /* Dialog helpers (these DO work today via the Tauri dialog plugin). */ export async function pickFile(name: string, extensions: string[], multiple = false): Promise { try { From 5276b14afaf485053fb34b3258edb880b7c7937b Mon Sep 17 00:00:00 2001 From: DexalGT Date: Tue, 4 Aug 2026 02:13:28 +0300 Subject: [PATCH 2/5] feat(bnkextract): fill the bank paths in from the picked BIN Picking a skin BIN already tells you which banks belong to it: they share a skin number and a mod root. bnk_locate_banks_for_bin walks up from the BIN to the directory holding data/ or assets/, then reuses the mod-folder matching to pair an audio bank with its events bank, so the folder drop and the file picker cannot disagree. Only empty fields are filled, so an explicit choice is never overwritten, and nothing convincing nearby leaves the fields alone rather than guessing. The scanner body moved into scan_folder_sets so both callers share one copy of the rules. --- src-tauri/src/commands/audio.rs | 148 ++++++++++++++++++++++++-- src-tauri/src/main.rs | 1 + src/pages/BnkExtract.tsx | 17 ++- src/pages/bnkextract/utils/backend.ts | 16 +++ 4 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index f508f2f9..371e11f0 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -383,14 +383,29 @@ pub async fn bnk_scan_mod_folder( skin_id: Option, ) -> Result, String> { tokio::task::spawn_blocking(move || { - let root = PathBuf::from(&folder_path); + Ok(scan_folder_sets( + &PathBuf::from(&folder_path), + skin_id.as_deref(), + )) + }) + .await + .map_err(|e| format!("scan task failed: {e}"))? +} + +/** Every audio/events/bin triple under `root`, best BIN first. + +Shared by the folder scan and by the single-BIN lookup so both agree on which bank pairs with +which — the matching rules here are the ones ported from the original Quartz mod processor and +are not worth having two copies of. */ +fn scan_folder_sets(root: &Path, skin_id: Option<&str>) -> Vec { + { if !root.exists() { - return Ok(Vec::new()); + return Vec::new(); } let mut all: Vec = Vec::new(); - walk_files(&root, &mut all); + walk_files(root, &mut all); - let skin = skin_id.as_deref().filter(|s| !s.is_empty()); + let skin = skin_id.filter(|s| !s.is_empty()); let skin_matches = |p: &Path| -> bool { match skin { None => true, @@ -443,7 +458,7 @@ pub async fn bnk_scan_mod_folder( score }; // Highest score first (stable so ties keep discovery order). - bins.sort_by(|a, b| bin_score(b).cmp(&bin_score(a))); + bins.sort_by_key(|b| std::cmp::Reverse(bin_score(b))); let selected_bin = bins .iter() .find(|p| skin_matches(p)) @@ -572,10 +587,84 @@ pub async fn bnk_scan_mod_folder( }) .collect(); - Ok(sets) + sets + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocatedBanks { + pub audio: String, + pub events: String, +} + +/// The `skinNN` number in a path, which is what pairs a BIN with its banks. +fn skin_id_from_path(path: &Path) -> Option { + let lowered = path.to_string_lossy().to_lowercase(); + let at = lowered.rfind("skin")?; + let digits: String = lowered[at + 4..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + (!digits.is_empty()).then(|| digits.trim_start_matches('0').to_string()) + .map(|trimmed| if trimmed.is_empty() { "0".into() } else { trimmed }) +} + +/** The directory to search for banks given a BIN somewhere inside a mod. + +A skin BIN sits under `.../data/characters//skins/` while its audio sits under +`.../assets/sounds/...`, so the search has to start from a shared ancestor. Walking up to the +directory holding `data` or `assets` finds the WAD root; failing that, a few levels up is still +better than the BIN's own folder, which never contains banks. */ +fn bank_search_root(bin: &Path) -> PathBuf { + let mut ancestors: Vec<&Path> = bin.ancestors().skip(1).collect(); + for dir in &ancestors { + let holds_root_marker = std::fs::read_dir(dir).is_ok_and(|entries| { + entries.flatten().any(|e| { + let name = e.file_name().to_string_lossy().to_lowercase(); + e.path().is_dir() && (name == "assets" || name == "data" || name == "sounds") + }) + }); + if holds_root_marker { + return dir.to_path_buf(); + } + } + ancestors.truncate(4); + ancestors + .last() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| bin.to_path_buf()) +} + +/** Find the audio and events banks that belong to a BIN the user just picked. + +Reuses the mod-folder matching, so the pairing is the same one the folder drop already produces. +Returns `None` rather than a guess when nothing convincing is nearby. */ +#[tauri::command] +pub async fn bnk_locate_banks_for_bin(bin_path: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let bin = PathBuf::from(&bin_path); + if !bin.is_file() { + return None; + } + let skin = skin_id_from_path(&bin); + let root = bank_search_root(&bin); + + let sets = scan_folder_sets(&root, skin.as_deref()); + // Prefer a set that resolved an events bank too — that is the pairing the + // tree actually needs to name anything. + let best = sets + .iter() + .find(|s| !s.events.is_empty()) + .or_else(|| sets.first())?; + + Some(LocatedBanks { + audio: best.audio.clone(), + events: best.events.clone(), + }) }) .await - .map_err(|e| format!("scan task failed: {e}"))? + .map_err(|e| format!("locate task failed: {e}")) } #[derive(Debug, Serialize)] @@ -1027,3 +1116,48 @@ fn extract_banks_from_game_blocking(args: GameBanksArgs) -> Result ({ ...prev, [kind]: picked })); + + /* Picking the BIN is enough to find its banks: they share a skin number + and a mod root, which is the same pairing a folder drop resolves. Only + empty fields are filled, so an explicit choice is never overwritten. */ + if (kind !== 'bin') return; + const located = await locateBanksForBin(picked); + if (!located) return; + setter((prev) => ({ + ...prev, + wpk: prev.wpk || located.audio, + bnk: prev.bnk || located.events, + })); + if (located.audio) { + setStatusMessage(`Found ${located.events ? 'audio + events banks' : 'audio bank'}`); + } }, []); const handleSetPath = useCallback((pane: Pane, kind: keyof PathSet, value: string) => { diff --git a/src/pages/bnkextract/utils/backend.ts b/src/pages/bnkextract/utils/backend.ts index 9fe14fcc..220e6d8b 100644 --- a/src/pages/bnkextract/utils/backend.ts +++ b/src/pages/bnkextract/utils/backend.ts @@ -115,6 +115,22 @@ export async function saveBank(root: BnkNode, outPath: string): Promise { await invoke('bnk_save_bank', { args: { root: toWireNode(root), outPath } }); } +/* Given a skin BIN, find the audio + events banks that belong to it. Returns + null when nothing convincing sits nearby, so the fields are left alone. */ +export async function locateBanksForBin( + binPath: string, +): Promise<{ audio: string; events: string } | null> { + try { + return await invoke<{ audio: string; events: string } | null>( + 'bnk_locate_banks_for_bin', + { binPath }, + ); + } catch (e) { + log.error('[BnkExtract] locateBanksForBin failed', e); + return null; + } +} + /* Encoding is in-process now — no external toolchain to check for or install. */ export async function convertToWem(inputPath: string): Promise { return toBytes(await invoke('audio_convert_to_wem', { inputPath })); From f12c0c242e007faed4778793137f867f795e85b8 Mon Sep 17 00:00:00 2001 From: DexalGT Date: Tue, 4 Aug 2026 02:23:09 +0300 Subject: [PATCH 3/5] docs(bnkextract): drop the stale references to the external toolchain --- src/pages/BnkExtract.tsx | 8 ++++---- src/pages/bnkextract/components/AudioSplitter.tsx | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/BnkExtract.tsx b/src/pages/BnkExtract.tsx index a8171e85..b39b98fe 100644 --- a/src/pages/BnkExtract.tsx +++ b/src/pages/BnkExtract.tsx @@ -4,10 +4,10 @@ the audio splitter overlay, the session / auto-extract / game-banks toolbar, the playback footer, per-format extract and the full context-menu + modal set. - Backend: BNK/WPK parsing, WEM decode and the external Wwise/vgmstream tooling - live behind the bnk_* / wwise_* / audio_* Tauri commands, wrapped by - ./bnkextract/utils/backend.ts. The audio splitter renders its waveform with - wavesurfer.js — see AudioSplitter.tsx. */ + Backend: BNK/WPK parsing, WEM decode and Wwise Vorbis encode all live behind + the bnk_* / audio_* Tauri commands, wrapped by ./bnkextract/utils/backend.ts. + Everything runs in-process — there is no external toolchain. The audio splitter + renders its waveform with wavesurfer.js — see AudioSplitter.tsx. */ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { Box } from '@mui/material'; diff --git a/src/pages/bnkextract/components/AudioSplitter.tsx b/src/pages/bnkextract/components/AudioSplitter.tsx index e66218ab..a74b3c98 100644 --- a/src/pages/bnkextract/components/AudioSplitter.tsx +++ b/src/pages/bnkextract/components/AudioSplitter.tsx @@ -3,9 +3,9 @@ pane / replace the source). The waveform + drag-to-create regions are rendered with wavesurfer.js (regions - plugin). Source audio is decoded to WAV in Rust (vgmstream / the native WEM - decoder); slicing and WAV encoding happen here in the browser via the Web Audio - API, and WEM re-encoding for "Replace Original" goes back through WwiseConsole. */ + plugin). Source audio is decoded to WAV in Rust; slicing and WAV encoding happen + here in the browser via the Web Audio API, and WEM re-encoding for "Replace + Original" goes back through the in-process Wwise Vorbis encoder. */ import { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; import { From b723463d833c2473218cea5bf731841a818fd25f Mon Sep 17 00:00:00 2001 From: DexalGT Date: Tue, 4 Aug 2026 02:36:42 +0300 Subject: [PATCH 4/5] perf(bnkextract): stop shipping every wem twice on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_banks returned audio_files alongside the tree, and both carried the full bytes of every embedded wem. The frontend declared audioFiles and hydrated it, but nothing ever read it — the tree leaves are what the UI works from. Tauri serializes Vec as a JSON number array, so each byte costs roughly three and a half characters and the duplicate doubled that again. Measured on aatrox_base_sfx_audio.bnk: a 3.02 MB bank with 170 entries produced 21.54 MB of IPC JSON, now 10.79 MB. The remaining 3.6x is the number-array encoding itself, which needs the tree to stop carrying audio at all to fix. --- src-tauri/crates/quartz-lib/src/audio/tree.rs | 5 +---- src/pages/bnkextract/utils/backend.ts | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src-tauri/crates/quartz-lib/src/audio/tree.rs b/src-tauri/crates/quartz-lib/src/audio/tree.rs index 6d19f94c..d623890a 100644 --- a/src-tauri/crates/quartz-lib/src/audio/tree.rs +++ b/src-tauri/crates/quartz-lib/src/audio/tree.rs @@ -46,7 +46,6 @@ pub struct BnkNode { #[serde(rename_all = "camelCase")] pub struct LoadBanksResult { pub tree: BnkNode, - pub audio_files: Vec, pub file_count: usize, #[serde(rename = "type")] pub kind: String, @@ -499,8 +498,7 @@ pub fn load_banks( &source_name }); - let audio_files: Vec = entries.iter().map(to_audio_data).collect(); - let file_count = audio_files.len(); + let file_count = entries.len(); let mut tree = group_audio_files(&entries, &mappings, &source_name); scope_ids(&mut tree, &scope_key, &[]); @@ -519,7 +517,6 @@ pub fn load_banks( Ok(Some(LoadBanksResult { tree, - audio_files, file_count, kind: final_type, })) diff --git a/src/pages/bnkextract/utils/backend.ts b/src/pages/bnkextract/utils/backend.ts index 220e6d8b..c1a72e05 100644 --- a/src/pages/bnkextract/utils/backend.ts +++ b/src/pages/bnkextract/utils/backend.ts @@ -7,7 +7,7 @@ import { invoke } from '@tauri-apps/api/core'; import { pickPath } from '@/components/explorer'; import { log } from '@/lib/util/logger'; -import type { AudioData, BnkNode, ExtractFormat } from '../types'; +import type { BnkNode, ExtractFormat } from '../types'; /* Tauri serializes Rust Vec as a JSON number array; rehydrate to bytes. */ function toBytes(value: unknown): Uint8Array { @@ -62,7 +62,6 @@ export interface LoadBanksArgs { export interface LoadBanksResult { tree: BnkNode; - audioFiles: AudioData[]; fileCount: number; type: string; } @@ -72,7 +71,6 @@ export async function loadBanks(args: LoadBanksArgs): Promise('bnk_load_banks', { args }); if (!result?.tree) return result; hydrateTree(result.tree); - result.audioFiles?.forEach((a) => { if (a.data) a.data = toBytes(a.data); }); return result; } From f6570a881bbcadf1ba9be2323360111d1d0e1964 Mon Sep 17 00:00:00 2001 From: DexalGT Date: Tue, 11 Aug 2026 02:31:58 +0300 Subject: [PATCH 5/5] test(audio): exercise the save path against real shipped banks Covers what actually breaks: byte-exact re-serialize, a no-op save that must not rewrite the file, and a header, bank id, HIRC and entry order that have to survive an edit. Skips when the fixtures are absent. --- .../crates/quartz-lib/tests/real_banks.rs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src-tauri/crates/quartz-lib/tests/real_banks.rs diff --git a/src-tauri/crates/quartz-lib/tests/real_banks.rs b/src-tauri/crates/quartz-lib/tests/real_banks.rs new file mode 100644 index 00000000..72259beb --- /dev/null +++ b/src-tauri/crates/quartz-lib/tests/real_banks.rs @@ -0,0 +1,173 @@ +/*! +Exercises the BnkExtract save path against real shipped banks and packages, which is the only +place the things that actually break show up: a header revision that must survive, a HIRC section +that must come back byte for byte, and a package whose entry names and dead slots have to be +reproduced. + +Real game audio is copyrighted and never committed, so every test skips when its fixture is +absent. Drop `.bnk` / `.wpk` samples in the path below to run them. +*/ + +use quartz_lib::audio::bank::{self, Bank}; +use ritoshark::audio::Wem; +use std::path::PathBuf; + +fn fixture(name: &str) -> Option> { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../../RitoShark-Crates/Sample-Files") + .join(name); + std::fs::read(path).ok() +} + +const BANKS: &[&str] = &[ + "aatrox_base_sfx_audio.bnk", + "aatrox_base_sfx_events.bnk", + "bank_v134_audio.bnk", + "bank_v134_bare.bnk", + "bank_v134_events.bnk", + "bank_v145_audio.bnk", + "bank_v145_bare.bnk", + "bank_v145_events.bnk", + "audio_package_4.wpk", + "audio_package_37.wpk", +]; + +#[test] +fn reading_and_writing_a_real_bank_is_byte_exact() { + let mut checked = 0; + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + let parsed = Bank::parse(&original).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!( + parsed.to_bytes().unwrap(), + original, + "{name} must re-serialize byte for byte" + ); + checked += 1; + } + eprintln!("round-tripped {checked} real banks"); +} + +#[test] +fn saving_an_untouched_tree_does_not_rewrite_the_file() { + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + let entries = bank::all_entries(&original).unwrap(); + let saved = bank::save_with_entries(&original, &entries).unwrap(); + + assert_eq!( + saved, original, + "{name}: saving without editing anything must not rewrite the file" + ); + } +} + +#[test] +fn a_save_keeps_the_header_and_every_section_we_do_not_model() { + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + let mut entries = bank::all_entries(&original).unwrap(); + let Some(first) = entries.first_mut() else { + continue; + }; + first.data = bank::to_wem(&first.data.clone(), None).unwrap(); + + let saved = bank::save_with_entries(&original, &entries).unwrap(); + let after = Bank::parse(&saved).unwrap(); + let before = Bank::parse(&original).unwrap(); + + assert_eq!( + after.entries().len(), + before.entries().len(), + "{name}: an edit must not drop entries" + ); + assert_eq!( + after.ids(), + before.ids(), + "{name}: entry ids and their order must survive" + ); + + if let (Bank::Bnk(a), Bank::Bnk(b)) = (&after, &before) { + assert_eq!(a.version(), b.version(), "{name}: header revision"); + assert_eq!(a.bank_id(), b.bank_id(), "{name}: bank id"); + for section in b + .sections + .iter() + .filter(|s| s.tag != *b"DIDX" && s.tag != *b"DATA") + { + let same = a.sections.iter().find(|s| s.tag == section.tag); + assert_eq!( + same.map(|s| &s.data), + Some(§ion.data), + "{name}: section {} must survive verbatim", + String::from_utf8_lossy(§ion.tag) + ); + } + } + } +} + +#[test] +fn every_embedded_payload_decodes() { + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + for entry in bank::all_entries(&original).unwrap() { + bank::decode_wem(&entry.data) + .unwrap_or_else(|e| panic!("{name}: entry {} did not decode: {e}", entry.id)); + } + } +} + +#[test] +fn a_replacement_lands_as_a_playable_wem_in_the_codec_it_replaced() { + let Some(original) = fixture("bank_v145_audio.bnk").or_else(|| fixture("bank_v134_audio.bnk")) + else { + return; + }; + let mut entries = bank::all_entries(&original).unwrap(); + let target = entries[0].id; + let original_payload = entries[0].data.clone(); + + /* What the replace dialog hands the backend once the user picks a non-wem file. */ + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36u32 + 2048).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&44100u32.to_le_bytes()); + wav.extend_from_slice(&88200u32.to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&2048u32.to_le_bytes()); + wav.extend_from_slice(&vec![0u8; 2048]); + + entries[0].data = bank::to_wem(&wav, None).unwrap(); + let saved = bank::save_with_entries(&original, &entries).unwrap(); + + let stored = Bank::parse(&saved).unwrap().entry(target).unwrap().to_vec(); + let wem = Wem::new(&stored).expect("a wav must be encoded into a real wem"); + assert_eq!(wem.format().sample_rate, 44100); + assert_eq!( + wem.format().codec, + Wem::new(&original_payload).unwrap().format().codec, + "a replacement must use the same codec as the sound it replaces" + ); + bank::decode_wem(&stored).expect("and it must decode back"); + assert!( + stored.len() < wav.len(), + "encoded {} bytes from a {} byte wav — that is PCM, not Vorbis", + stored.len(), + wav.len() + ); +}