From 65a13e0bda493d23ce62ee2dc6789deb3466a4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E5=8F=AF=E8=83=BD=E5=BE=88=E5=BB=A2?= Date: Tue, 4 Feb 2025 13:57:58 +0800 Subject: [PATCH] :memo: add more docs --- src/bin/mltd/extract.rs | 15 +++++-- src/mltd/extract/audio.rs | 86 ++++++++++++++++++++++++++---------- src/mltd/extract/mod.rs | 2 + src/mltd/extract/text.rs | 45 +++++++++++++++++++ src/mltd/lib.rs | 1 + src/mltd/net/asset_ripper.rs | 33 +++++++++++--- src/mltd/net/mod.rs | 2 +- 7 files changed, 150 insertions(+), 34 deletions(-) diff --git a/src/bin/mltd/extract.rs b/src/bin/mltd/extract.rs index 23c645a..ab8e1cd 100644 --- a/src/bin/mltd/extract.rs +++ b/src/bin/mltd/extract.rs @@ -86,13 +86,13 @@ pub async fn extract_files(args: &ExtractorArgs) -> Result<(), Error> { .map(|i| AssetRipper::new(&args.asset_ripper_path, port_start + i)) .collect::, _>>()? .into_iter() - .map(|a| Mutex::new(a)) + .map(Mutex::new) .collect::>(); let files = args .input_paths .iter() - .filter_map(|p| match glob::glob(&p) { + .filter_map(|p| match glob::glob(p) { Ok(paths) => Some(paths), Err(e) => { log::warn!("failed to glob `{}`: {}", p, e); @@ -127,6 +127,7 @@ pub async fn extract_files(args: &ExtractorArgs) -> Result<(), Error> { Ok(()) } +/// Extracts a single .unity3d file. async fn extract_file

( path: &P, asset_ripper: &mut AssetRipper, @@ -157,6 +158,7 @@ where Ok(()) } +/// Extracts a single asset according to its class. async fn extract_asset( _bundle_no: usize, _collection_no: usize, @@ -175,6 +177,9 @@ async fn extract_asset( Ok(()) } +/// Extracts a TextAsset. +/// +/// Audio assets are binary TextAsset, so they are handled here as well. async fn extract_text_asset( info: AssetInfo, asset_ripper: &mut AssetRipper, @@ -200,9 +205,10 @@ async fn extract_text_asset( let file_path = tmpdir.path().join(info.original_path.as_ref().unwrap()); match &info.entry.2 { + // CRI .acb and .awb audio n if n.ends_with(".acb") || n.ends_with(".awb") => { // remove .bytes extension for vgmstream - std::fs::rename(&file_path, &file_path.with_extension(""))?; + std::fs::rename(&file_path, file_path.with_extension(""))?; let file_path = file_path.with_extension(""); let output_path = args @@ -220,7 +226,7 @@ async fn extract_text_asset( tokio::task::spawn_blocking(move || { let mut options = ffmpeg_next::Dictionary::new(); for (key, value) in &args { - options.set(&key, &value); + options.set(key, value); } if !args.is_empty() { log::trace!("audio options: {:#?}", options); @@ -235,6 +241,7 @@ async fn extract_text_asset( }) .await??; } + // AES-192-CBC encrypted plot text n if n.ends_with(".gtx") => { let output_path = args .output diff --git a/src/mltd/extract/audio.rs b/src/mltd/extract/audio.rs index cda66ce..f25f9a6 100644 --- a/src/mltd/extract/audio.rs +++ b/src/mltd/extract/audio.rs @@ -1,3 +1,5 @@ +//! Audio transcoding. + use std::collections::VecDeque; use std::ffi::{c_int, c_uint}; use std::path::Path; @@ -8,42 +10,70 @@ use vgmstream::{Options, StreamFile, VgmStream}; use crate::Error; +/// An encoder that transcodes game audio to the target codec. pub struct Encoder<'a> { - pub options: Option>, - + /// VgmStream stream file. pub vgmstream: VgmStream, + + /// Original audio channel layout. pub from_channel_layout: ffmpeg_next::ChannelLayout, + /// Original sample format. pub from_sample_format: ffmpeg_next::format::Sample, + /// Original sample rate. pub from_sample_rate: i32, + /// FFmpeg encoder. pub encoder: ffmpeg_next::codec::encoder::audio::Encoder, + /// FFmpeg encoder options. + pub options: Option>, + + /// FFmpeg output context. pub output: ffmpeg_next::format::context::Output, + /// FFmpeg resampler context. pub resampler: ffmpeg_next::software::resampling::Context, + /// FFmpeg audio frame. pub frame: ffmpeg_next::frame::Audio, + /// Audio sample count. pub sample_count: i64, + + /// Audio next presentation timestamp. pub next_pts: i64, - queue: VecDeque, + /// Fifo for audio data. + fifo: VecDeque, } impl<'a> Encoder<'a> { + /// Default audio frame size. + /// + /// Some codecs accept variable frame sizes, and this is used for those codecs. pub const DEFAULT_FRAME_SIZE: u32 = 4096; + /// Opens the encoder with the given parameters. + /// + /// VgmStream will decode the input game audio, and FFmpeg will enocde with the given + /// codec and options. The output file will be truncated if it exists. + /// + /// # Errors + /// + /// [`Error::VGMStream`]: if vgmstream cannot identify the input file format. + /// + /// [`Error::FFmpeg`]: if ffmpeg encoder initialization failed. pub fn open

( - input: &P, - output: &P, - codec: &str, - options: Option>, + input_file: &P, + output_dir: &P, + output_codec: &str, + output_options: Option>, ) -> Result where P: AsRef + ?Sized, { let mut vgmstream = VgmStream::new()?; - let sf = StreamFile::open(&vgmstream, input)?; + let sf = StreamFile::open(&vgmstream, input_file)?; vgmstream.open_song(&mut Options { libsf: &sf, format_id: 0, @@ -56,12 +86,12 @@ impl<'a> Encoder<'a> { log::trace!("audio format: {:#?}", acb_fmt); - let mut output = match options { - Some(ref o) => ffmpeg_next::format::output_with(output, o.clone()), - None => ffmpeg_next::format::output(output.as_ref()), + let mut output = match output_options { + Some(ref o) => ffmpeg_next::format::output_with(output_dir, o.clone()), + None => ffmpeg_next::format::output(output_dir.as_ref()), }?; - let codec = ffmpeg_next::encoder::find_by_name(codec) + let codec = ffmpeg_next::encoder::find_by_name(output_codec) .ok_or(Error::Generic("Failed to find encoder".to_owned()))?; let mut encoder = ffmpeg_next::codec::Context::new_with_codec(codec).encoder().audio()?; @@ -86,7 +116,7 @@ impl<'a> Encoder<'a> { encoder.set_flags(flag | ffmpeg_next::codec::Flags::GLOBAL_HEADER); } - let encoder = match options { + let encoder = match output_options { Some(ref o) => encoder.open_with(o.clone()), None => encoder.open(), }?; @@ -117,12 +147,12 @@ impl<'a> Encoder<'a> { )?; Ok(Self { - options, vgmstream, from_channel_layout, from_sample_format, from_sample_rate: acb_fmt.sample_rate, encoder, + options: output_options, output, resampler, frame, @@ -130,10 +160,13 @@ impl<'a> Encoder<'a> { sample_count: 0, next_pts: 0, - queue: VecDeque::new(), + fifo: VecDeque::new(), }) } + /// Encodes the next audio frame and writes the encoded packets to the output file. + /// + /// Returns `false` if there is more audio data to encode. fn write_frame(&mut self, eof: bool) -> Result { match eof { false => self.encoder.send_frame(&self.frame), @@ -187,18 +220,18 @@ impl<'a> Encoder<'a> { break; } - self.queue.extend(buf); - if self.queue.len() >= needed_len { + self.fifo.extend(buf); + if self.fifo.len() >= needed_len { break; } } - let samples = match self.queue.len() { + let samples = match self.fifo.len() { 0 => return None, - len if len < needed_len => std::mem::take(&mut self.queue).into(), + len if len < needed_len => std::mem::take(&mut self.fifo).into(), _ => { - let mut rest = self.queue.split_off(needed_len); - std::mem::swap(&mut rest, &mut self.queue); + let mut rest = self.fifo.split_off(needed_len); + std::mem::swap(&mut rest, &mut self.fifo); Vec::from(rest) } @@ -223,6 +256,8 @@ impl<'a> Encoder<'a> { } /// Encodes the next audio frame. + /// + /// Returns `false` if there is more audio data to encode. fn write_audio_frame(&mut self) -> Result { if let Some(frame) = self.get_audio_frame() { assert_eq!(self.resampler.delay(), None, "there should be no delay"); @@ -259,6 +294,11 @@ impl<'a> Encoder<'a> { self.write_frame(true) } + /// Encodes the audio streams. + /// + /// # Errors + /// + /// [`Error::FFmpeg`]: if encoding failed. pub fn encode(&mut self) -> Result<(), Error> { match self.options { Some(ref o) => { @@ -328,7 +368,8 @@ fn get_supported_formats( } } -/* +/// Returns a list of supported audio formats. +#[cfg(any())] fn get_supported_formats_new( encoder: &ffmpeg_next::codec::encoder::Encoder, ) -> Result, Error> { @@ -358,7 +399,6 @@ fn get_supported_formats_new( .map(|fmt| (*fmt).into()) .collect()) } -*/ fn choose_format( supported_formats: &[ffmpeg_next::format::Sample], diff --git a/src/mltd/extract/mod.rs b/src/mltd/extract/mod.rs index a2eba80..ade39dc 100644 --- a/src/mltd/extract/mod.rs +++ b/src/mltd/extract/mod.rs @@ -1,2 +1,4 @@ +//! Functions to extract assets from MLTD. + pub mod audio; pub mod text; diff --git a/src/mltd/extract/text.rs b/src/mltd/extract/text.rs index c034cba..56a8f23 100644 --- a/src/mltd/extract/text.rs +++ b/src/mltd/extract/text.rs @@ -1,3 +1,5 @@ +//! Encrypt and decrypt text assets in MLTD. + use aes::cipher::block_padding::Pkcs7; use aes::cipher::inout::InOutBufReserved; use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; @@ -6,8 +8,16 @@ use cbc::{Decryptor, Encryptor}; use crate::Error; +/// The key used to derive [`MLTD_TEXT_DECRYPT_KEY`] and +/// [`MLTD_TEXT_DECRYPT_IV`]. pub const MLTD_TEXT_PBKDF2_HMAC_SHA1_KEY: &[u8; 8] = b"Millicon"; + +/// The salt used to derive [`MLTD_TEXT_DECRYPT_KEY`] and +/// [`MLTD_TEXT_DECRYPT_IV`]. pub const MLTD_TEXT_PBKDF2_HMAC_SHA1_SALT: &[u8; 9] = b"DAISUL___"; + +/// The number of iterations used to derive [`MLTD_TEXT_DECRYPT_KEY`] and +/// [`MLTD_TEXT_DECRYPT_IV`]. pub const MLTD_TEXT_PBKDF2_HMAC_SHA1_ROUNDS: u32 = 1000; /// The AES-192-CBC key used to decrypt the text asset. @@ -33,9 +43,28 @@ pub const MLTD_TEXT_DECRYPT_IV: &[u8; 16] = &[ 0x12, 0x2c, 0x5f, 0xad, 0xcc, 0xa3, 0x68, 0x5d, ]; +/// AES-192-CBC encryptor for text assets in MLTD. pub type MltdTextEncryptor = Encryptor; + +/// AES-192-CBC decryptor for text assets in MLTD. pub type MltdTextDecryptor = Decryptor; +/// Encrypts text using AES-192-CBC with MLTD's key and IV. +/// +/// The input text is padded with PKCS7 padding. +/// +/// # Errors +/// +/// [`Error::Aes`]: if encryption failed. +/// +/// # Example +/// +/// ```no_run +/// use mltd::extract::text::encrypt_text; +/// +/// let text = b"Hello, world!"; +/// let cipher = encrypt_text(text).unwrap(); +/// ``` pub fn encrypt_text(text: &[u8]) -> Result, Error> { let encryptor = MltdTextEncryptor::new(MLTD_TEXT_DECRYPT_KEY.into(), MLTD_TEXT_DECRYPT_IV.into()); @@ -49,6 +78,22 @@ pub fn encrypt_text(text: &[u8]) -> Result, Error> { Ok(buf.to_owned()) } +/// Decrypts text using AES-192-CBC with MLTD's key and IV. +/// +/// The output text is unpadded with PKCS7 padding. +/// +/// # Errors +/// +/// [`Error::Aes`]: if decryption failed. +/// +/// # Example +/// +/// ```no_run +/// use mltd::extract::text::decrypt_text; +/// +/// let cipher = b"Hello, world!"; +/// let text = decrypt_text(cipher).unwrap(); +/// ``` pub fn decrypt_text(cipher: &[u8]) -> Result, Error> { let decryptor = MltdTextDecryptor::new(MLTD_TEXT_DECRYPT_KEY.into(), MLTD_TEXT_DECRYPT_IV.into()); diff --git a/src/mltd/lib.rs b/src/mltd/lib.rs index dd32224..17c0fc4 100644 --- a/src/mltd/lib.rs +++ b/src/mltd/lib.rs @@ -6,6 +6,7 @@ #![warn(clippy::print_stderr)] #![warn(clippy::print_stdout)] +#![warn(missing_docs)] pub mod asset; mod error; diff --git a/src/mltd/net/asset_ripper.rs b/src/mltd/net/asset_ripper.rs index 8c87e0e..21c5e41 100644 --- a/src/mltd/net/asset_ripper.rs +++ b/src/mltd/net/asset_ripper.rs @@ -1,3 +1,5 @@ +//! AssetRipper client. + use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; use std::path::Path; @@ -8,19 +10,30 @@ use reqwest::Response; use crate::Error; +/// Asset entry on `/Collections/View`. #[derive(Debug, Clone, Hash, Eq, PartialEq)] pub struct AssetEntry(pub i64, pub String, pub String); +/// Information about an asset on `/Assets/View`. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] pub struct AssetInfo { + /// Asset entry on `/Collections/View`. pub entry: AssetEntry, + + /// Original path of the asset before bundled. pub original_path: Option, + + /// Name of the bundle file. pub asset_bundle_name: String, } -/// Asset ripper process. +/// AssetRipper client. +#[derive(Debug)] pub struct AssetRipper { + /// Base URL of AssetRipper. base_url: String, + /// AssetRipper child process. process: Option, } @@ -31,9 +44,9 @@ impl AssetRipper { P: AsRef + ?Sized, { let process = match Command::new(path.as_ref().as_os_str()) - .args(&["--port", &port.to_string()]) - .args(&["--launch-browser", "false"]) - .args(&["--log", "false"]) + .args(["--port", &port.to_string()]) + .args(["--launch-browser", "false"]) + .args(["--log", "false"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -57,10 +70,12 @@ impl AssetRipper { Ok(Self { base_url: format!("http://localhost:{}", port), process: Some(process) }) } + /// Connects th an existing AssetRipper instance with the given host and port. pub fn connect(host: &str, port: u16) -> Result { Ok(Self { base_url: format!("http://{}:{}", host, port), process: None }) } + /// Loads an asset or a folder into the AssetRipper. pub async fn load

(&mut self, path: &P) -> Result<(), Error> where P: AsRef + ?Sized, @@ -86,6 +101,7 @@ impl AssetRipper { Ok(()) } + /// Sends a GET request with the given path parameter to AssetRipper. pub async fn send_request(&mut self, url: &str, path: &str) -> Result { let url = format!("{}/{}", &self.base_url, url); let client = reqwest::Client::new(); @@ -106,7 +122,7 @@ impl AssetRipper { pub async fn bundles(&mut self) -> Result, Error> { let path = r#"{"P":[]}"#; - let html = match self.send_request("Bundles/View", &path).await?.text().await { + let html = match self.send_request("Bundles/View", path).await?.text().await { Ok(html) => html, Err(e) => return Err(Error::ResponseDeserialize(e)), }; @@ -203,6 +219,7 @@ impl AssetRipper { Ok(text.parse()?) } + /// Returns information about the specified asset. pub async fn asset_info( &mut self, bundle_no: usize, @@ -243,6 +260,7 @@ impl AssetRipper { }) } + /// Returns the JSON representation of the specified asset. pub async fn asset_json( &mut self, bundle_no: usize, @@ -256,10 +274,11 @@ impl AssetRipper { match self.send_request("Assets/Json", &path).await?.json().await { Ok(json) => Ok(json), - Err(e) => return Err(Error::ResponseDeserialize(e)), + Err(e) => Err(Error::ResponseDeserialize(e)), } } + /// Returns the text data stream of the specified asset. pub async fn asset_text( &mut self, bundle_no: usize, @@ -274,6 +293,7 @@ impl AssetRipper { Ok(self.send_request("Assets/Text", &path).await?.bytes_stream()) } + /// Exports the primary content on the AssetRipper. pub async fn export_primary

(&mut self, path: &P) -> Result<(), Error> where P: AsRef + ?Sized, @@ -306,6 +326,7 @@ impl AssetRipper { Ok(()) } + /// Downloads the latest version of AssetRipper. pub async fn download_latest() -> Result<(), Error> { let client = reqwest::Client::new(); let base_url = "https://github.com/AssetRipper/AssetRipper/releases/latest/download/"; diff --git a/src/mltd/net/mod.rs b/src/mltd/net/mod.rs index d1567c0..8478937 100644 --- a/src/mltd/net/mod.rs +++ b/src/mltd/net/mod.rs @@ -1,4 +1,4 @@ -//! This module provides functions related to network requests. +//! Functions related to network requests. mod asset_ripper; mod matsuri_api;