>, _matches: &ArgMatches) -> Result<()> {
unimplemented!()
}
diff --git a/src/bin/cmd/util.rs b/src/bin/cmd/util.rs
new file mode 100644
index 0000000..f5846d9
--- /dev/null
+++ b/src/bin/cmd/util.rs
@@ -0,0 +1,131 @@
+use std::ffi::OsStr;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use tokio::fs;
+use tokio_stream::wrappers::ReadDirStream;
+use tokio_stream::StreamExt;
+
+#[tracing::instrument]
+pub async fn resolve_bundle_path(path: P) -> Vec
+where
+ P: AsRef + std::fmt::Debug,
+{
+ let dir = match fs::read_dir(path.as_ref()).await {
+ Ok(dir) => {
+ tracing::trace!(is_dir = true);
+ dir
+ }
+ Err(err) => {
+ if err.kind() != io::ErrorKind::NotADirectory {
+ tracing::error!(%err, "Failed to read path");
+ }
+ let paths = vec![PathBuf::from(path.as_ref())];
+ tracing::debug!(is_dir = false, resolved_paths = ?paths);
+ return paths;
+ }
+ };
+
+ let stream = ReadDirStream::new(dir);
+ let paths: Vec = stream
+ .filter_map(|entry| {
+ if let Ok(path) = entry.map(|e| e.path()) {
+ match path.file_name().and_then(OsStr::to_str) {
+ Some(name) if name.len() == 16 => {
+ if name.chars().all(|c| c.is_ascii_hexdigit()) {
+ Some(path)
+ } else {
+ None
+ }
+ }
+ _ => None,
+ }
+ } else {
+ None
+ }
+ })
+ .collect()
+ .await;
+
+ tracing::debug!(resolved_paths = ?paths);
+
+ paths
+}
+
+#[tracing::instrument(skip_all)]
+pub async fn collect_bundle_paths(paths: I) -> Vec
+where
+ I: Iterator- + std::fmt::Debug,
+{
+ let tasks = paths.map(|p| async move {
+ match tokio::spawn(async move { resolve_bundle_path(&p).await }).await {
+ Ok(paths) => paths,
+ Err(err) => {
+ tracing::error!(%err, "failed to spawn task to resolve bundle paths");
+ vec![]
+ }
+ }
+ });
+
+ let results = futures_util::future::join_all(tasks).await;
+ results.into_iter().flatten().collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use tempfile::tempdir;
+ use tokio::process::Command;
+
+ use super::resolve_bundle_path;
+
+ #[tokio::test]
+ async fn resolve_single_file() {
+ let path = PathBuf::from("foo");
+ let paths = resolve_bundle_path(&path).await;
+ assert_eq!(paths.len(), 1);
+ assert_eq!(paths[0], path);
+ }
+
+ #[tokio::test]
+ async fn resolve_empty_directory() {
+ let dir = tempdir().expect("failed to create temporary directory");
+ let paths = resolve_bundle_path(dir).await;
+ assert!(paths.is_empty());
+ }
+
+ #[tokio::test]
+ async fn resolve_mixed_directory() {
+ let dir = tempdir().expect("failed to create temporary directory");
+ let temp_dir = dir.path();
+
+ let bundle_names = ["000957451622b061", "000b7a0d86775831", "00231e322d01c363"];
+ let other_names = ["settings.ini", "metadata_database.db"];
+ let _ = futures::future::try_join_all(
+ bundle_names
+ .into_iter()
+ .chain(other_names.into_iter())
+ .map(|name| async move {
+ Command::new("touch")
+ .arg(name)
+ .current_dir(temp_dir)
+ .status()
+ .await?;
+
+ Ok::<_, std::io::Error>(name)
+ }),
+ )
+ .await
+ .expect("failed to create temporary files");
+
+ let paths = resolve_bundle_path(dir).await;
+
+ assert_eq!(bundle_names.len(), paths.len());
+
+ for p in paths.iter() {
+ let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap();
+ assert!(bundle_names.iter().find(|&n| n == &name).is_some());
+ }
+ }
+}
diff --git a/src/bin/cmd/watch.rs b/src/bin/cmd/watch.rs
index d3e9d96..07e8b4a 100644
--- a/src/bin/cmd/watch.rs
+++ b/src/bin/cmd/watch.rs
@@ -3,8 +3,7 @@ use std::sync::Arc;
use clap::{value_parser, Arg, ArgMatches, Command};
use color_eyre::eyre::Result;
-
-use dtmt::Context;
+use tokio::sync::RwLock;
pub(crate) fn command_definition() -> Command {
Command::new("watch")
@@ -22,6 +21,6 @@ pub(crate) fn command_definition() -> Command {
}
#[tracing::instrument(skip_all)]
-pub(crate) async fn run(_ctx: Arc, _matches: &ArgMatches) -> Result<()> {
+pub(crate) async fn run(_ctx: Arc>, _matches: &ArgMatches) -> Result<()> {
unimplemented!()
}
diff --git a/src/bin/dtmt.rs b/src/bin/dtmt.rs
index d947c00..5118c0b 100644
--- a/src/bin/dtmt.rs
+++ b/src/bin/dtmt.rs
@@ -1,9 +1,11 @@
#![feature(io_error_more)]
+#![feature(let_chains)]
use std::sync::Arc;
use clap::{command, Arg, ArgAction};
use color_eyre::eyre::Result;
+use tokio::sync::RwLock;
use tracing_error::ErrorLayer;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
@@ -15,6 +17,7 @@ mod cmd {
pub mod bundle;
pub mod murmur;
pub mod new;
+ mod util;
pub mod watch;
}
@@ -55,13 +58,14 @@ async fn main() -> Result<()> {
}
let ctx = Context::new();
+ let ctx = Arc::new(RwLock::new(ctx));
match matches.subcommand() {
- Some(("bundle", sub_matches)) => cmd::bundle::run(Arc::new(ctx), sub_matches).await?,
- Some(("murmur", sub_matches)) => cmd::murmur::run(Arc::new(ctx), sub_matches).await?,
- Some(("new", sub_matches)) => cmd::new::run(Arc::new(ctx), sub_matches).await?,
- Some(("build", sub_matches)) => cmd::build::run(Arc::new(ctx), sub_matches).await?,
- Some(("watch", sub_matches)) => cmd::watch::run(Arc::new(ctx), sub_matches).await?,
+ Some(("bundle", sub_matches)) => cmd::bundle::run(ctx, sub_matches).await?,
+ Some(("murmur", sub_matches)) => cmd::murmur::run(ctx, sub_matches).await?,
+ Some(("new", sub_matches)) => cmd::new::run(ctx, sub_matches).await?,
+ Some(("build", sub_matches)) => cmd::build::run(ctx, sub_matches).await?,
+ Some(("watch", sub_matches)) => cmd::watch::run(ctx, sub_matches).await?,
_ => unreachable!(
"clap is configured to require a subcommand, and they're all handled above"
),
diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs
new file mode 100644
index 0000000..e33f3e6
--- /dev/null
+++ b/src/bundle/mod.rs
@@ -0,0 +1,98 @@
+use std::io::SeekFrom;
+use std::sync::Arc;
+
+use color_eyre::eyre::{self, Context, Result};
+use color_eyre::{Help, SectionExt};
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt};
+use tokio::sync::RwLock;
+
+use crate::oodle;
+
+#[derive(Debug, PartialEq)]
+enum BundleFormat {
+ Darktide,
+}
+
+impl TryFrom for BundleFormat {
+ type Error = color_eyre::Report;
+
+ fn try_from(value: u32) -> Result {
+ match value {
+ 0xF0000007 => Ok(Self::Darktide),
+ _ => Err(eyre::eyre!("Unknown bundle format '{:08X}'", value)),
+ }
+ }
+}
+
+async fn read_u32(mut r: R) -> Result
+where
+ R: AsyncRead + AsyncSeek + std::marker::Unpin,
+{
+ let res = r.read_u32_le().await.wrap_err("failed to read u32");
+
+ if res.is_err() {
+ let pos = r.stream_position().await;
+ if pos.is_ok() {
+ res.with_section(|| pos.unwrap().to_string().header("Position: "))
+ } else {
+ res
+ }
+ } else {
+ res
+ }
+}
+
+/// Returns a decompressed version of the bundle data.
+/// This is mainly useful for debugging purposes or
+/// to manullay inspect the raw data.
+#[tracing::instrument(skip(ctx, r, w))]
+pub async fn decompress(ctx: Arc>, mut r: R, mut w: W) -> Result<()>
+where
+ R: AsyncRead + AsyncSeek + std::marker::Unpin,
+ W: AsyncWrite + std::marker::Unpin,
+{
+ let format = read_u32(&mut r).await.and_then(BundleFormat::try_from)?;
+
+ if format != BundleFormat::Darktide {
+ return Err(eyre::eyre!("Unknown bundle format: {:?}", format));
+ }
+
+ // Skip unknown 4 bytes
+ r.seek(SeekFrom::Current(4)).await?;
+
+ let num_entries = read_u32(&mut r).await? as i64;
+
+ // Skip unknown 256 bytes
+ r.seek(SeekFrom::Current(256)).await?;
+ // Skip file meta
+ r.seek(SeekFrom::Current(num_entries * 20)).await?;
+
+ let num_chunks = read_u32(&mut r).await? as usize;
+ // Skip chunk sizes
+ r.seek(SeekFrom::Current(num_chunks as i64 * 4)).await?;
+
+ {
+ let size_1 = read_u32(&mut r).await?;
+
+ // Skip unknown 4 bytes
+ r.seek(SeekFrom::Current(4)).await?;
+
+ // NOTE: Unknown why there sometimes is a second value.
+ if size_1 == 0x0 {
+ // Skip unknown 4 bytes
+ r.seek(SeekFrom::Current(8)).await?;
+ }
+ }
+
+ let chunks_start = r.stream_position().await?;
+
+ {
+ // Pipe the header into the output
+ r.seek(SeekFrom::Start(0)).await?;
+ let mut buf = vec![0; chunks_start as usize];
+ r.read_exact(&mut buf).await?;
+ w.write_all(&buf).await?;
+ }
+
+ oodle::decompress(ctx, r, w, num_chunks).await
+}
diff --git a/src/context.rs b/src/context.rs
index 0436bf5..79c73fd 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,8 +1,10 @@
-pub struct Context {}
+pub struct Context {
+ pub oodle: Option,
+}
impl Context {
pub fn new() -> Self {
- Self {}
+ Self { oodle: None }
}
}
diff --git a/src/lib.rs b/src/lib.rs
index b7c5344..a25ce0c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,6 @@
+mod bundle;
mod context;
+mod oodle;
+
+pub use bundle::decompress;
pub use context::Context;
diff --git a/src/oodle.rs b/src/oodle.rs
new file mode 100644
index 0000000..1c83ed5
--- /dev/null
+++ b/src/oodle.rs
@@ -0,0 +1,106 @@
+use std::process::Stdio;
+use std::sync::Arc;
+
+use color_eyre::eyre::Context;
+use color_eyre::{eyre, Help, Result, SectionExt};
+use nanorand::Rng;
+use tokio::fs::File;
+use tokio::io::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, BufReader, BufWriter};
+use tokio::process::Command;
+use tokio::sync::RwLock;
+use tokio::{fs, io};
+use tracing::Instrument;
+
+#[tracing::instrument(level = "debug", skip(ctx, r, w))]
+pub(crate) async fn decompress(
+ ctx: Arc>,
+ r: R,
+ w: W,
+ num_chunks: usize,
+) -> Result<()>
+where
+ R: AsyncRead + AsyncSeek + std::marker::Unpin,
+ W: AsyncWrite + std::marker::Unpin,
+{
+ let mut r = BufReader::new(r);
+ let mut w = BufWriter::new(w);
+
+ let padding_start = r.stream_position().await?;
+
+ let mut rng = nanorand::WyRand::new();
+ let leaf = rng.generate::();
+
+ let tmp_dir = std::env::temp_dir().join(format!("dtmt-{}", leaf));
+
+ fs::create_dir(&tmp_dir).await?;
+ tracing::trace!(tmp_dir = %tmp_dir.display());
+
+ let in_path = tmp_dir.join("in.bin");
+ let out_path = tmp_dir.join("out.bin");
+
+ {
+ let mut in_file = File::create(&in_path).await?;
+ io::copy(&mut r, &mut in_file)
+ .await
+ .wrap_err("failed to write compressed data to file")
+ .with_section(|| in_path.display().to_string().header("Path"))?;
+ }
+
+ {
+ let _span = tracing::span!(tracing::Level::INFO, "Run decompression helper");
+ async {
+ let mut cmd = {
+ let ctx = ctx.read().await;
+ Command::new(ctx.oodle.as_ref().expect("`oodle` arg not passed through"))
+ };
+
+ let cmd = cmd
+ .args(["-v", "-v", "-v"])
+ .args(["--padding", &padding_start.to_string()])
+ .args(["--chunks", &num_chunks.to_string()])
+ .arg("decompress")
+ .arg(&in_path)
+ .arg(&out_path)
+ .stdin(Stdio::null());
+
+ tracing::debug!(?cmd, "Running Oodle decompression helper");
+
+ let res = cmd
+ .output()
+ .await
+ .wrap_err("failed to spawn the Oodle decompression helper")?;
+
+ tracing::trace!(
+ "Output of Oodle decompression helper:\n{}",
+ String::from_utf8_lossy(&res.stdout)
+ );
+
+ if !res.status.success() {
+ let stderr = String::from_utf8_lossy(&res.stderr);
+ let stdout = String::from_utf8_lossy(&res.stdout);
+ return Err(eyre::eyre!("failed to run Oodle decompression helper")
+ .with_section(move || stdout.to_string().header("Logs:"))
+ .with_section(move || stderr.to_string().header("Stderr:")));
+ }
+
+ Ok(())
+ }
+ .instrument(_span)
+ .await
+ .with_section(|| tmp_dir.display().to_string().header("Temp Dir:"))?
+ }
+
+ {
+ let mut out_file = File::open(&out_path).await?;
+ io::copy(&mut out_file, &mut w)
+ .await
+ .wrap_err("failed to read decompressed file")
+ .with_section(|| out_path.display().to_string().header("Path"))?;
+ }
+
+ fs::remove_dir_all(tmp_dir)
+ .await
+ .wrap_err("failed to remove temporary directory")?;
+
+ Ok(())
+}