use crate::{InstallCommands, InstallType as CliInstallType, OutputFormat, wago_api};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use indicatif::{ProgressBar, ProgressStyle};
use ngdp_bpsv::{BpsvBuilder, BpsvFieldType, BpsvValue};
use ngdp_cache::cached_cdn_client::CachedCdnClient;
use ngdp_cache::hybrid_version_client::HybridVersionClient;
use ribbit_client::Region;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tact_parser::download::DownloadManifest;
use tact_parser::encoding::EncodingFile;
use tact_parser::install::InstallManifest;
use tracing::{debug, error, info, warn};
#[derive(Debug, Clone)]
struct FileEntry {
path: String,
ckey: Vec<u8>, size: u64,
priority: i8,
}
#[derive(Debug, Clone)]
struct ArchiveLocation {
archive_hash: String,
offset: usize,
size: usize,
}
#[derive(Debug)]
struct ArchiveIndex {
map: HashMap<String, ArchiveLocation>, }
impl ArchiveIndex {
fn new() -> Self {
Self {
map: HashMap::new(),
}
}
fn lookup(&self, ekey: &[u8]) -> Option<&ArchiveLocation> {
let lookup_key = hex::encode(ekey).to_uppercase();
let result = self.map.get(&lookup_key);
if result.is_none() && !self.map.is_empty() {
debug!(
"EKey {} not found in {} archive entries",
lookup_key,
self.map.len()
);
}
result
}
fn parse_and_add_index(
&mut self,
archive_hash: &str,
index_data: &[u8],
) -> Result<usize, Box<dyn std::error::Error>> {
use byteorder::{BigEndian, ReadBytesExt};
use std::io::{Cursor, Read};
const BLOCK_SIZE: usize = 4096;
const ENTRIES_PER_BLOCK: usize = 170;
const _ENTRY_SIZE: usize = 24; const BLOCK_CHECKSUM_SIZE: usize = 16;
let num_blocks = index_data.len() / BLOCK_SIZE;
let mut cursor = Cursor::new(index_data);
let mut entries_added = 0;
debug!(
"Parsing archive index {}: {} blocks ({} bytes total)",
archive_hash,
num_blocks,
index_data.len()
);
for block_idx in 0..num_blocks {
for entry_idx in 0..ENTRIES_PER_BLOCK {
let mut ekey_bytes = [0u8; 16];
if cursor.read_exact(&mut ekey_bytes).is_err() {
debug!("Failed to read entry {} in block {}", entry_idx, block_idx);
break;
}
let size = cursor.read_u32::<BigEndian>()? as usize;
let offset = cursor.read_u32::<BigEndian>()? as usize;
let ekey_hex = hex::encode(ekey_bytes).to_uppercase();
if ekey_hex == "00000000000000000000000000000000" || size == 0 {
continue;
}
if size > 0 && size < 100_000_000 {
let location = ArchiveLocation {
archive_hash: archive_hash.to_string(),
offset,
size,
};
self.map.insert(ekey_hex, location);
entries_added += 1;
}
}
let mut checksum = [0u8; BLOCK_CHECKSUM_SIZE];
let _ = cursor.read_exact(&mut checksum);
}
debug!(
"Parsed archive index {}: {} entries added from {} blocks",
archive_hash, entries_added, num_blocks
);
Ok(entries_added)
}
}
async fn download_file_with_archive(
cdn_client: &CachedCdnClient,
archive_index: &ArchiveIndex,
cdn_host: &str,
cdn_path: &str,
ekey_hex: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let ekey_bytes = hex::decode(ekey_hex)?;
debug!(
"Looking up EKey {} (len={}) in archive index...",
ekey_hex,
ekey_bytes.len()
);
if let Some(location) = archive_index.lookup(&ekey_bytes) {
info!(
"✓ Found {} in archive {} at offset {}, size {}",
ekey_hex, location.archive_hash, location.offset, location.size
);
info!(
"Attempting archive byte-range download from {}",
location.archive_hash
);
info!(
"Attempting archive range download from {}",
location.archive_hash
);
match download_archive_range(
cdn_client,
cdn_path,
&location.archive_hash,
location.offset,
location.size,
)
.await
{
Ok(data) => {
if data.starts_with(b"BLTE") {
match blte::decompress_blte(data.clone(), None) {
Ok(decompressed) => return Ok(decompressed),
Err(e) => {
warn!("Failed to decompress BLTE from archive: {}", e);
return Ok(data);
}
}
} else {
return Ok(data);
}
}
Err(e) => {
warn!(
"Failed to download from archive {}: {}",
location.archive_hash, e
);
}
}
} else {
warn!(
"❌ EKey {} NOT found in any archive - falling back to loose file download",
ekey_hex
);
}
info!("⬇️ Attempting loose file download for {}", ekey_hex);
match cdn_client.download_data(cdn_host, cdn_path, ekey_hex).await {
Ok(response) => {
let data = response.bytes().await?;
if data.starts_with(b"BLTE") {
match blte::decompress_blte(data.to_vec(), None) {
Ok(decompressed) => Ok(decompressed),
Err(e) => {
warn!("Failed to decompress BLTE: {}", e);
Ok(data.to_vec())
}
}
} else {
Ok(data.to_vec())
}
}
Err(e) => Err(Box::new(e)),
}
}
async fn download_archive_range(
_cdn_client: &CachedCdnClient,
cdn_path: &str,
archive_hash: &str,
offset: usize,
size: usize,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let hosts = vec![
"blzddist1-a.akamaihd.net",
"level3.blizzard.com",
"us.cdn.blizzard.com",
"cdn.arctium.tools",
"tact.mirror.reliquaryhq.com",
];
for host in &hosts {
let url = format!(
"http://{}/{}/data/{}/{}/{}",
host,
cdn_path,
&archive_hash[0..2],
&archive_hash[2..4],
archive_hash
);
let client = reqwest::Client::new();
let range_header = format!("bytes={}-{}", offset, offset + size - 1);
match client.get(&url).header("Range", range_header).send().await {
Ok(response) => {
if response.status().is_success() {
match response.bytes().await {
Ok(data) => {
debug!(
"Downloaded {} bytes from archive {} ({})",
data.len(),
archive_hash,
host
);
return Ok(data.to_vec());
}
Err(e) => warn!("Failed to read archive range response: {}", e),
}
} else {
warn!(
"Archive range request failed: {} from {}",
response.status(),
host
);
}
}
Err(e) => warn!("Archive range request failed from {}: {}", host, e),
}
}
Err("Failed to download archive range from all CDNs".into())
}
async fn download_archive_index(
_cdn_client: &CachedCdnClient,
cdn_path: &str,
archive_hash: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
use std::path::PathBuf;
use tokio::fs;
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("ngdp")
.join("cdn")
.join(cdn_path)
.join("data")
.join(&archive_hash[0..2])
.join(&archive_hash[2..4]);
let cache_file = cache_dir.join(format!("{}.index", archive_hash));
if cache_file.exists() {
debug!("Loading archive index {} from cache", archive_hash);
match fs::read(&cache_file).await {
Ok(bytes) => {
info!(
"✓ Archive index {} loaded from cache ({} bytes)",
archive_hash,
bytes.len()
);
return Ok(bytes);
}
Err(e) => {
warn!("Failed to read cached archive index: {}", e);
}
}
}
let hosts = vec![
"blzddist1-a.akamaihd.net",
"level3.blizzard.com",
"us.cdn.blizzard.com",
"cdn.arctium.tools",
"tact.mirror.reliquaryhq.com",
];
let client = reqwest::Client::new();
for host in &hosts {
let url = format!(
"http://{}/{}/data/{}/{}/{}.index",
host,
cdn_path,
&archive_hash[0..2],
&archive_hash[2..4],
archive_hash
);
debug!("Downloading archive index from: {}", url);
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.bytes().await {
Ok(bytes) => {
info!(
"✓ Downloaded archive index {} from {} ({} bytes)",
archive_hash,
host,
bytes.len()
);
let decompressed = if bytes.starts_with(b"BLTE") {
match blte::decompress_blte(bytes.to_vec(), None) {
Ok(data) => {
debug!(
"✓ Decompressed BLTE archive index: {} -> {} bytes",
bytes.len(),
data.len()
);
data
}
Err(e) => {
warn!("Failed to decompress BLTE archive index: {}", e);
bytes.to_vec()
}
}
} else {
bytes.to_vec()
};
if let Err(e) = fs::create_dir_all(&cache_dir).await {
warn!("Failed to create cache directory: {}", e);
} else if let Err(e) = fs::write(&cache_file, &decompressed).await {
warn!("Failed to cache archive index {}: {}", archive_hash, e);
} else {
debug!(
"✓ Cached archive index {} at {:?}",
archive_hash, cache_file
);
}
return Ok(decompressed);
}
Err(e) => {
warn!("Failed to read response body from {}: {}", host, e);
}
}
} else {
debug!(
"HTTP {} from {} for archive index {}",
response.status(),
host,
archive_hash
);
}
}
Err(e) => {
debug!("Request failed to {}: {}", host, e);
}
}
}
Err(format!(
"Failed to download archive index {} from all CDNs",
archive_hash
)
.into())
}
#[derive(Debug, Clone)]
struct GameInstallConfig {
product: String,
path: PathBuf,
build: Option<String>,
region: Region,
install_type: CliInstallType,
verify: bool,
dry_run: bool,
format: OutputFormat,
}
#[derive(Debug)]
struct InstallationPlanDisplay {
product: String,
path: PathBuf,
install_type: CliInstallType,
manifest_type: String,
required_files: usize,
optional_files: usize,
total_size: u64,
format: OutputFormat,
}
#[derive(Debug)]
struct BuildInfoConfig<'a> {
install_path: &'a Path,
product: &'a str,
version_entry: &'a ribbit_client::VersionEntry,
build_config_hash: &'a str,
cdn_config_hash: &'a str,
build_config: &'a tact_parser::config::BuildConfig,
cdn_entry: &'a ribbit_client::CdnEntry,
region: Region,
}
pub async fn handle(
cmd: InstallCommands,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match cmd {
InstallCommands::Game {
product,
path,
build,
region,
install_type,
resume,
verify,
dry_run,
max_concurrent: _,
tags: _,
} => {
let region = region.parse::<Region>().unwrap_or(Region::US);
if resume {
let build_info_path = path.join(".build.info");
if build_info_path.exists() {
info!(
"🔄 Resume mode: Continuing existing installation at {:?}",
path
);
return resume_installation(path.as_path(), format).await;
} else {
return Err(format!(
"Resume requested but no .build.info found at {}. Start with metadata-only installation first.",
path.display()
).into());
}
}
let config = GameInstallConfig {
product,
path,
build,
region,
install_type,
verify,
dry_run,
format,
};
handle_game_installation(config).await
}
InstallCommands::Repair {
path,
verify_checksums,
dry_run,
max_concurrent: _,
} => handle_repair_installation(path, verify_checksums, dry_run, format).await,
}
}
async fn handle_game_installation(
config: GameInstallConfig,
) -> Result<(), Box<dyn std::error::Error>> {
let GameInstallConfig {
product,
path,
build,
region,
install_type,
verify,
dry_run,
format,
} = config;
info!("🚀 Starting installation of {} to {:?}", product, path);
if dry_run {
info!("🔍 DRY RUN mode - no files will be downloaded");
}
let version_entry = if let Some(build_str) = &build {
info!("🔍 Searching for build {} in Wago Tools API...", build_str);
let builds_response = wago_api::fetch_builds().await?;
let builds = wago_api::filter_builds_by_product(builds_response, &product);
if let Some(wago_build) = wago_api::find_build_by_id(&builds, build_str) {
info!(
"✓ Found build {} in historical data: {}",
build_str, wago_build.version
);
let version_client = HybridVersionClient::new(region).await?;
let current_versions = version_client.get_product_versions(&product).await?;
let current_cdn_config = current_versions
.entries
.first()
.map(|v| v.cdn_config.clone())
.unwrap_or_default();
let cdn_config = wago_build.cdn_config.clone().unwrap_or(current_cdn_config);
use ribbit_client::VersionEntry;
VersionEntry {
region: region.to_string(),
build_config: wago_build.build_config.clone(),
cdn_config,
key_ring: None,
build_id: wago_api::extract_build_id(&wago_build.version)
.and_then(|s| s.parse().ok())
.unwrap_or(0),
versions_name: wago_build.version.clone(),
product_config: wago_build.product_config.clone().unwrap_or_default(),
}
} else {
info!("🔍 Build not found in historical data, checking current versions...");
let version_client = HybridVersionClient::new(region).await?;
let versions = version_client.get_product_versions(&product).await?;
versions
.entries
.iter()
.find(|v| v.build_id.to_string() == *build_str || v.versions_name == *build_str)
.ok_or_else(|| {
format!(
"Build '{}' not found in current or historical versions",
build_str
)
})?
.clone()
}
} else {
info!("📋 Querying latest product version (HTTPS primary, Ribbit fallback)...");
let version_client = HybridVersionClient::new(region).await?;
let versions = version_client.get_product_versions(&product).await?;
versions
.entries
.first()
.ok_or("No versions available for product")?
.clone()
};
info!(
"📦 Selected build: {} ({})",
version_entry.versions_name, version_entry.build_id
);
let build_config_hash = &version_entry.build_config;
let cdn_config_hash = &version_entry.cdn_config;
info!("📥 Downloading configurations...");
let version_client = HybridVersionClient::new(region).await?;
let cdns = version_client.get_product_cdns(&product).await?;
let cdn_entry = cdns.entries.first().ok_or("No CDN servers available")?;
let cdn_host = cdn_entry.hosts.first().ok_or("No CDN hosts available")?;
let cdn_path = &cdn_entry.path;
debug!("Using CDN host: {} with path: {}", cdn_host, cdn_path);
let cdn_client = CachedCdnClient::new().await?;
cdn_client.add_primary_hosts(cdn_entry.hosts.iter().cloned());
cdn_client.add_fallback_host("cdn.arctium.tools");
cdn_client.add_fallback_host("tact.mirror.reliquaryhq.com");
let build_config_data = cdn_client
.download_build_config(&cdn_entry.hosts[0], cdn_path, build_config_hash)
.await?
.bytes()
.await?;
let build_config =
tact_parser::config::BuildConfig::parse(std::str::from_utf8(&build_config_data)?)?;
info!("✓ Build configuration loaded");
let cdn_config_data = cdn_client
.download_cdn_config(&cdn_entry.hosts[0], cdn_path, cdn_config_hash)
.await?
.bytes()
.await?;
let _cdn_config =
tact_parser::config::ConfigFile::parse(std::str::from_utf8(&cdn_config_data)?)?;
info!("✓ CDN configuration loaded");
info!("📥 Downloading system files...");
let encoding_value = build_config
.config
.get_value("encoding")
.ok_or("Missing encoding field")?;
let encoding_parts: Vec<&str> = encoding_value.split_whitespace().collect();
let encoding_ekey = if encoding_parts.len() >= 2 {
encoding_parts[1]
} else {
encoding_parts[0]
};
debug!("Downloading encoding file with ekey: {}", encoding_ekey);
let encoding_data = cdn_client
.download_data(&cdn_entry.hosts[0], cdn_path, encoding_ekey)
.await?
.bytes()
.await?;
let encoding_data = if encoding_data.starts_with(b"BLTE") {
blte::decompress_blte(encoding_data.to_vec(), None)?
} else {
encoding_data.to_vec()
};
let encoding_file = EncodingFile::parse(&encoding_data)?;
info!(
"✓ Encoding file loaded: {} CKey entries, {} EKey mappings",
encoding_file.ckey_count(),
encoding_file.ekey_count()
);
info!("📦 Downloading ALL archive indices in parallel for complete coverage!");
let mut archive_index = ArchiveIndex::new();
let cdn_config_parsed =
tact_parser::config::CdnConfig::parse(std::str::from_utf8(&cdn_config_data)?)?;
let all_archives = cdn_config_parsed.archives();
info!("Found {} total archives available", all_archives.len());
info!(
"🚀 Downloading ALL {} archive indices in parallel (10 concurrent)...",
all_archives.len()
);
use futures::stream::{self, StreamExt};
info!(
"📥 Loading {} cached archive indices sequentially...",
all_archives.len()
);
let mut results = Vec::new();
for (i, archive_hash) in all_archives.iter().enumerate() {
let result = download_archive_index(&cdn_client, cdn_path, archive_hash).await;
results.push((i, archive_hash.to_string(), result));
if (i + 1) % 100 == 0 || i + 1 == all_archives.len() {
info!("📦 Loaded {}/{} archive indices", i + 1, all_archives.len());
}
}
let mut successful_archives = 0;
for (i, archive_hash, result) in results {
match result {
Ok(index_data) => match archive_index.parse_and_add_index(&archive_hash, &index_data) {
Ok(entries) => {
debug!(
"✓ [{}/{}] Indexed archive {} with {} entries",
i + 1,
all_archives.len(),
archive_hash,
entries
);
successful_archives += 1;
}
Err(e) => {
warn!("Failed to parse archive index {}: {}", archive_hash, e);
}
},
Err(e) => {
warn!("Failed to download archive index {}: {}", archive_hash, e);
}
}
}
info!(
"✓ Archive indices loaded: {}/{} archives indexed, {} total entries",
successful_archives,
all_archives.len(),
archive_index.map.len()
);
info!("Build Config Info:");
info!(" - Build Config Hash: {}", build_config_hash);
info!(" - CDN Config Hash: {}", cdn_config_hash);
if let Some(build_id) = build_config.config.get_value("build-id") {
info!(" - Build ID from config: {}", build_id);
}
if let Some(encoding_value) = build_config.config.get_value("encoding") {
info!(" - Encoding value: {}", encoding_value);
}
if let Some(install_value) = build_config.config.get_value("install") {
info!(" - Install value: {}", install_value);
}
info!(
"✓ Archive indices loaded, total entries: {}",
archive_index.map.len()
);
info!("DEBUG: About to get sample CKeys from encoding file...");
info!("Sample content keys from encoding file:");
for (i, ckey) in encoding_file.get_sample_ckeys(5).iter().enumerate() {
info!(" CKey[{}]: {}", i, ckey);
}
info!("DEBUG: Finished getting sample CKeys, moving to manifest processing...");
info!(
"🔄 Starting manifest download based on installation type: {:?}",
install_type
);
let (file_entries, manifest_type) = match install_type {
CliInstallType::Minimal => {
info!("📥 Processing minimal installation - using download manifest");
let download_value = build_config
.config
.get_value("download")
.ok_or("Missing download field")?;
let download_parts: Vec<&str> = download_value.split_whitespace().collect();
let download_ekey = if download_parts.len() >= 2 {
download_parts[1].to_string()
} else {
let ckey = download_parts[0];
let ekey_bytes = encoding_file
.lookup_by_ckey(&hex::decode(ckey)?)
.and_then(|e| e.encoding_keys.first())
.ok_or("Download file encoding key not found in encoding table")?;
hex::encode(ekey_bytes)
};
info!(
"📥 Downloading download manifest with ekey: {}",
download_ekey
);
let download_data = cdn_client
.download_data(&cdn_entry.hosts[0], cdn_path, &download_ekey)
.await?
.bytes()
.await?;
let download_data = if download_data.starts_with(b"BLTE") {
blte::decompress_blte(download_data.to_vec(), None)?
} else {
download_data.to_vec()
};
let download_manifest = DownloadManifest::parse(&download_data)?;
info!(
"✓ Download manifest loaded: {} files (filtering for minimal install)",
download_manifest.entries.len()
);
info!("Sample EKeys from download manifest:");
for (i, (ekey, entry)) in download_manifest.entries.iter().enumerate() {
if i < 5 {
info!(
" Download[{}]: {} (size: {} bytes)",
i,
hex::encode(ekey),
entry.compressed_size
);
} else {
break;
}
}
info!("Testing first few download manifest EKeys in archive indices:");
for (i, (ekey, entry)) in download_manifest.entries.iter().take(5).enumerate() {
let test_ekey = hex::encode(ekey);
match archive_index.lookup(ekey) {
Some(location) => {
info!(
" ✓ Download[{}]: {} FOUND in archive {} at offset {} (size: {})",
i, test_ekey, location.archive_hash, location.offset, location.size
);
}
None => {
info!(
" ✗ Download[{}]: {} NOT FOUND in archives (size: {})",
i, test_ekey, entry.compressed_size
);
}
}
}
let entries: Vec<FileEntry> = download_manifest
.entries
.iter()
.take(10)
.map(|(ekey, entry)| FileEntry {
path: format!("file_{}", hex::encode(&ekey[..4])), ckey: ekey.clone(), size: entry.compressed_size,
priority: 0,
})
.collect();
info!(
"Selected {} files for minimal download install",
entries.len()
);
(entries, "download")
}
CliInstallType::Full | CliInstallType::Custom => {
info!("📥 Processing FULL/CUSTOM installation - using download manifest for all files");
let download_value = build_config
.config
.get_value("download")
.ok_or("Missing download field")?;
let download_parts: Vec<&str> = download_value.split_whitespace().collect();
let download_ekey = if download_parts.len() >= 2 {
download_parts[1].to_string()
} else {
let ckey = download_parts[0];
let ekey_bytes = encoding_file
.lookup_by_ckey(&hex::decode(ckey)?)
.and_then(|e| e.encoding_keys.first())
.ok_or("Download file encoding key not found in encoding table")?;
hex::encode(ekey_bytes)
};
debug!("Downloading download manifest with ekey: {}", download_ekey);
let download_data = cdn_client
.download_data(&cdn_entry.hosts[0], cdn_path, &download_ekey)
.await?
.bytes()
.await?;
let download_data = if download_data.starts_with(b"BLTE") {
blte::decompress_blte(download_data.to_vec(), None)?
} else {
download_data.to_vec()
};
let download_manifest = DownloadManifest::parse(&download_data)?;
info!(
"✓ Download manifest loaded: {} files (complete game)",
download_manifest.entries.len()
);
let mut total_entries = 0;
let mut skipped_not_in_encoding = 0;
let skipped_bad_size = 0;
let entries: Vec<FileEntry> = download_manifest
.entries
.iter()
.enumerate()
.filter_map(|(i, (_ekey, e))| {
total_entries += 1;
if let Some(ckey) = encoding_file.lookup_by_ekey(&e.ekey) {
let file_size = encoding_file
.get_file_size(ckey)
.unwrap_or(e.compressed_size);
Some(FileEntry {
path: format!("data/{:08x}", i), ckey: ckey.clone(), size: file_size, priority: e.priority,
})
} else {
skipped_not_in_encoding += 1;
if skipped_not_in_encoding <= 5 {
debug!("EKey {} not found in encoding file", hex::encode(&e.ekey));
}
None }
})
.collect();
info!(
"Download manifest processing: {} total entries, {} included, {} not in encoding, {} bad size",
total_entries,
entries.len(),
skipped_not_in_encoding,
skipped_bad_size
);
(entries, "download")
}
CliInstallType::MetadataOnly => {
(Vec::new(), "metadata-only")
}
};
info!("📋 Building file manifest...");
let mut total_size = 0u64;
let mut required_files = 0;
let mut optional_files = 0;
for entry in &file_entries {
let is_required = match install_type {
CliInstallType::Minimal => is_required_file(&entry.path),
CliInstallType::Full => true,
CliInstallType::Custom => {
entry.priority <= 0 }
CliInstallType::MetadataOnly => false, };
if is_required {
required_files += 1;
} else {
optional_files += 1;
}
total_size += entry.size;
}
let plan = InstallationPlanDisplay {
product: product.clone(),
path: path.clone(),
install_type,
manifest_type: manifest_type.to_string(),
required_files,
optional_files,
total_size,
format,
};
display_installation_plan(&plan)?;
info!("🗄️ Creating directory structure...");
tokio::fs::create_dir_all(&path).await?;
tokio::fs::create_dir_all(path.join("Data")).await?;
tokio::fs::create_dir_all(path.join("Data/data")).await?;
tokio::fs::create_dir_all(path.join("Data/config")).await?;
info!("✓ Directory structure created");
info!("📄 Writing .build.info file...");
let build_info_config = BuildInfoConfig {
install_path: path.as_path(),
product: &product,
version_entry: &version_entry,
build_config_hash,
cdn_config_hash,
build_config: &build_config,
cdn_entry,
region,
};
write_build_info_file(build_info_config).await?;
info!("✓ .build.info file written");
if dry_run {
info!("✅ Dry run complete - no files were downloaded");
return Ok(());
}
info!("📄 Writing configuration files to Data/config/...");
let build_config_subdir = format!("{}/{}", &build_config_hash[0..2], &build_config_hash[2..4]);
let build_config_dir = path.join("Data/config").join(&build_config_subdir);
tokio::fs::create_dir_all(&build_config_dir).await?;
let build_config_path = build_config_dir.join(build_config_hash);
tokio::fs::write(&build_config_path, &build_config_data).await?;
info!(
"✓ Saved build config: {}/{}",
build_config_subdir, build_config_hash
);
let cdn_config_subdir = format!("{}/{}", &cdn_config_hash[0..2], &cdn_config_hash[2..4]);
let cdn_config_dir = path.join("Data/config").join(&cdn_config_subdir);
tokio::fs::create_dir_all(&cdn_config_dir).await?;
let cdn_config_path = cdn_config_dir.join(cdn_config_hash);
tokio::fs::write(&cdn_config_path, &cdn_config_data).await?;
info!(
"✓ Saved CDN config: {}/{}",
cdn_config_subdir, cdn_config_hash
);
let encoding_info_path = path.join("Data/config").join("encoding.info");
let encoding_info = format!(
"# Encoding file information\n\
# Generated by cascette-rs\n\
Encoding-Hash: {}\n\
CKey-Count: {}\n\
EKey-Count: {}\n\
Build: {}\n\
Product: {}\n\
Region: {}\n",
build_config
.config
.get_value("encoding")
.unwrap_or("unknown")
.split_whitespace()
.next()
.unwrap_or("unknown"),
encoding_file.ckey_count(),
encoding_file.ekey_count(),
version_entry.build_id,
product,
region
);
tokio::fs::write(&encoding_info_path, encoding_info).await?;
info!("✓ Saved encoding info: encoding.info");
if install_type == CliInstallType::MetadataOnly {
info!("✅ Metadata-only installation complete");
info!("📋 Created: .build.info and Data/config/ with CDN-style structure");
info!("💡 Use this for quick client comparison or as base for full installation");
return Ok(());
}
info!("📥 Downloading files...");
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
.progress_chars("#>-"),
);
let files_to_download: Vec<_> = file_entries
.iter()
.filter(|entry| {
match install_type {
CliInstallType::Minimal => {
let include = manifest_type == "download" || is_required_file(&entry.path);
if !include {
debug!("Skipping file for minimal install: {}", entry.path);
} else {
debug!("Including file for minimal install: {}", entry.path);
}
include
}
CliInstallType::Full => true,
CliInstallType::Custom => entry.priority <= 0, CliInstallType::MetadataOnly => false, }
})
.collect();
info!(
"Files selected for download: {} out of {} total files",
files_to_download.len(),
file_entries.len()
);
if files_to_download.is_empty() {
error!("❌ No files selected for download! Check filtering logic.");
return Ok(());
}
info!("DEBUG: Passed file selection check, continuing to download setup...");
for (i, entry) in files_to_download.iter().take(3).enumerate() {
info!(
"File {}: {} (ckey: {})",
i + 1,
entry.path,
hex::encode(&entry.ckey)
);
}
info!(
"Downloading {} files with parallel processing (max 10 concurrent)",
files_to_download.len()
);
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let downloaded_count = Arc::new(AtomicUsize::new(0));
let error_count = Arc::new(AtomicUsize::new(0));
let pb = Arc::new(pb);
let cdn_client = Arc::new(cdn_client);
let archive_index = Arc::new(archive_index);
let encoding_file = Arc::new(encoding_file);
let path = Arc::new(path);
info!("Starting download of {} files...", files_to_download.len());
info!("DEBUG: Testing async runtime...");
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
info!("DEBUG: Async runtime works!");
info!("DEBUG: Creating stream iterator...");
info!("Starting download of {} files", files_to_download.len());
if files_to_download.is_empty() {
warn!("No files selected for download!");
return Ok(());
}
info!("Files to download: {}", files_to_download.len());
for (i, entry) in files_to_download.iter().take(5).enumerate() {
info!(
" File {}: {} (size: {} bytes)",
i + 1,
entry.path,
entry.size
);
}
let total_files = files_to_download.len();
info!("Starting to process {} files concurrently", total_files);
let download_futures = stream::iter(files_to_download)
.map(|entry| {
let cdn_client = cdn_client.clone();
let archive_index = archive_index.clone();
let encoding_file = encoding_file.clone();
let path = path.clone();
let pb = pb.clone();
let downloaded_count = downloaded_count.clone();
let error_count = error_count.clone();
let manifest_type = manifest_type.to_string();
let entry = entry.clone();
async move {
info!("DEBUG: Entered async closure for file: {}", entry.path);
info!(
"Processing file: {} (ckey: {})",
entry.path,
hex::encode(&entry.ckey)
);
let file_dir = path.join("Data/data");
if let Err(e) = tokio::fs::create_dir_all(&file_dir).await {
warn!("Failed to create directory {}: {}", file_dir.display(), e);
error_count.fetch_add(1, Ordering::Relaxed);
return;
}
let download_key = if manifest_type == "install" {
debug!(
"Looking up ckey: {} (path: {})",
hex::encode(&entry.ckey),
entry.path
);
if let Some(encoding_entry) = encoding_file.lookup_by_ckey(&entry.ckey) {
if encoding_entry.size > 10_000_000_000 {
debug!(
"Skipping file with suspicious size: {} bytes ({}GB) for path: {}",
encoding_entry.size,
encoding_entry.size / 1_000_000_000,
entry.path
);
return;
}
if let Some(ekey) = encoding_entry.encoding_keys.first() {
debug!(
"Found ekey: {} for ckey: {}",
hex::encode(ekey),
hex::encode(&entry.ckey)
);
hex::encode(ekey)
} else {
warn!(
"No encoding key found for content key: {} (path: {}) - skipping",
hex::encode(&entry.ckey),
entry.path
);
return;
}
} else {
warn!(
"Content key not found in encoding file: {} (path: {}) - skipping",
hex::encode(&entry.ckey),
entry.path
);
return; }
} else {
hex::encode(&entry.ckey)
};
info!(
"Attempting to download file: {} with key: {}",
entry.path, download_key
);
info!("DEBUG: About to call download_file_with_archive...");
info!("Archive index has {} entries", archive_index.map.len());
match download_file_with_archive(
&cdn_client,
&archive_index,
&cdn_entry.hosts[0],
cdn_path,
&download_key,
)
.await
{
Ok(data) => {
let subdir1 = &download_key[0..2];
let subdir2 = &download_key[2..4];
let file_dir = path.join("Data/data").join(subdir1).join(subdir2);
if let Err(e) = tokio::fs::create_dir_all(&file_dir).await {
warn!("Failed to create directory {}: {}", file_dir.display(), e);
error_count.fetch_add(1, Ordering::Relaxed);
return;
}
let file_path = file_dir.join(&download_key);
info!(
"Writing {} bytes to path: {}",
data.len(),
file_path.display()
);
if let Err(e) = tokio::fs::write(&file_path, &data).await {
warn!("Failed to write {}: {}", entry.path, e);
error_count.fetch_add(1, Ordering::Relaxed);
} else {
downloaded_count.fetch_add(1, Ordering::Relaxed);
pb.inc(entry.size);
info!(
"✓ Downloaded and wrote {} ({} bytes to {})",
entry.path,
data.len(),
file_path.display()
);
}
}
Err(e) => {
warn!("Failed to download {}: {}", entry.path, e);
error_count.fetch_add(1, Ordering::Relaxed);
}
}
}
})
.buffer_unordered(50) .collect::<Vec<_>>();
info!("DEBUG: Awaiting all download futures...");
let results: Vec<_> = download_futures.await;
info!(
"Download futures completed - processed {} results",
results.len()
);
info!("DEBUG: Stream processing completed");
info!("Completed processing all file download tasks");
pb.finish_with_message("Download complete!");
let final_downloaded = downloaded_count.load(Ordering::Relaxed);
let final_errors = error_count.load(Ordering::Relaxed);
info!(
"✅ Installation completed: {} files downloaded, {} errors",
final_downloaded, final_errors
);
if verify {
info!("🔍 Verifying installation...");
info!("✓ Verification complete");
}
Ok(())
}
fn is_required_file(path: &str) -> bool {
if path.ends_with(".exe") || path.ends_with(".dll") || path.ends_with(".so") {
return true;
}
if path.contains("config") || path.ends_with(".ini") || path.ends_with(".xml") {
return true;
}
if path.starts_with("Data/") {
if path.ends_with(".dbc") || path.ends_with(".db2") {
return true;
}
if path.contains("patch") || path.contains("locale") || path.contains("enUS") {
return true;
}
if path.contains("base") || path.contains("core") || path.contains("common") {
return true;
}
}
if path.ends_with("Wow.exe") || path.ends_with("WowClassic.exe") {
return true;
}
false
}
fn display_installation_plan(
plan: &InstallationPlanDisplay,
) -> Result<(), Box<dyn std::error::Error>> {
let InstallationPlanDisplay {
product,
path,
install_type,
manifest_type,
required_files,
optional_files,
total_size,
format,
} = plan;
match format {
OutputFormat::Json | OutputFormat::JsonPretty => {
let plan = serde_json::json!({
"product": product,
"path": path,
"install_type": format!("{:?}", install_type),
"manifest_type": manifest_type,
"required_files": required_files,
"optional_files": optional_files,
"total_files": required_files + optional_files,
"total_size": total_size,
"total_size_human": format_bytes(*total_size),
});
if matches!(format, OutputFormat::JsonPretty) {
println!("{}", serde_json::to_string_pretty(&plan)?);
} else {
println!("{}", serde_json::to_string(&plan)?);
}
}
OutputFormat::Text => {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["Installation Plan", "Value"]);
table.add_row(vec![Cell::new("Product"), Cell::new(product)]);
table.add_row(vec![
Cell::new("Installation Path"),
Cell::new(path.display()),
]);
table.add_row(vec![
Cell::new("Installation Type"),
Cell::new(format!("{install_type:?}")),
]);
table.add_row(vec![Cell::new("Manifest Type"), Cell::new(manifest_type)]);
table.add_row(vec![Cell::new("Required Files"), Cell::new(required_files)]);
table.add_row(vec![Cell::new("Optional Files"), Cell::new(optional_files)]);
table.add_row(vec![
Cell::new("Total Files"),
Cell::new(required_files + optional_files),
]);
table.add_row(vec![
Cell::new("Total Size"),
Cell::new(if *install_type == CliInstallType::MetadataOnly {
"Metadata only".to_string()
} else {
format_bytes(*total_size)
}),
]);
println!("{table}");
}
OutputFormat::Bpsv => {
return Err("BPSV format not supported for installation plan".into());
}
}
Ok(())
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, UNITS[unit_index])
}
async fn write_build_info_file(
config: BuildInfoConfig<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
let BuildInfoConfig {
install_path,
product,
version_entry,
build_config_hash,
cdn_config_hash,
build_config,
cdn_entry,
region,
} = config;
let install_value = build_config.config.get_value("install").unwrap_or("");
let install_parts: Vec<&str> = install_value.split_whitespace().collect();
let install_key = if install_parts.len() >= 2 {
install_parts[1] } else {
install_parts.first().copied().unwrap_or("") };
let cdn_hosts = cdn_entry.hosts.join(" ");
let cdn_servers = if cdn_entry.servers.is_empty() {
cdn_entry
.hosts
.iter()
.flat_map(|host| {
vec![
format!("http://{}/?maxhosts=4", host),
format!("https://{}/?maxhosts=4&fallback=1", host),
]
})
.collect::<Vec<_>>()
.join(" ")
} else {
cdn_entry.servers.join(" ")
};
let tags = format!(
"Windows x86_64 {}? acct-{}?",
region.as_str().to_uppercase(),
region.as_str().to_uppercase()
);
let mut builder = BpsvBuilder::new();
builder.add_field("Branch", BpsvFieldType::String(0))?;
builder.add_field("Active", BpsvFieldType::Decimal(1))?;
builder.add_field("Build Key", BpsvFieldType::Hex(16))?;
builder.add_field("CDN Key", BpsvFieldType::Hex(16))?;
builder.add_field("Install Key", BpsvFieldType::Hex(16))?;
builder.add_field("IM Size", BpsvFieldType::Decimal(4))?;
builder.add_field("CDN Path", BpsvFieldType::String(0))?;
builder.add_field("CDN Hosts", BpsvFieldType::String(0))?;
builder.add_field("CDN Servers", BpsvFieldType::String(0))?;
builder.add_field("Tags", BpsvFieldType::String(0))?;
builder.add_field("Armadillo", BpsvFieldType::String(0))?;
builder.add_field("Last Activated", BpsvFieldType::String(0))?;
builder.add_field("Version", BpsvFieldType::String(0))?;
builder.add_field("KeyRing", BpsvFieldType::Hex(16))?;
builder.add_field("Product", BpsvFieldType::String(0))?;
builder.add_row(vec![
BpsvValue::String(region.as_str().to_string()), BpsvValue::Decimal(1), BpsvValue::Hex(build_config_hash.to_string()), BpsvValue::Hex(cdn_config_hash.to_string()), BpsvValue::Hex(install_key.to_string()), BpsvValue::Decimal(0), BpsvValue::String(cdn_entry.path.clone()), BpsvValue::String(cdn_hosts), BpsvValue::String(cdn_servers), BpsvValue::String(tags), BpsvValue::String(String::new()), BpsvValue::String(String::new()), BpsvValue::String(version_entry.versions_name.clone()), BpsvValue::Hex(version_entry.key_ring.as_deref().unwrap_or("").to_string()), BpsvValue::String(product.to_string()), ])?;
let build_info_content = builder.build_string()?;
let build_info_path = install_path.join(".build.info");
tokio::fs::write(&build_info_path, build_info_content).await?;
debug!("Written .build.info to: {}", build_info_path.display());
Ok(())
}
async fn resume_installation(
install_path: &Path,
_format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
info!("📋 Reading installation metadata from .build.info...");
let build_info_path = install_path.join(".build.info");
let build_info_content = tokio::fs::read_to_string(&build_info_path).await?;
let build_info = ngdp_bpsv::BpsvDocument::parse(&build_info_content)?;
let rows = build_info.rows();
if rows.is_empty() {
return Err("No entries found in .build.info file".into());
}
let schema = build_info.schema();
let row = &rows[0]; let product = row
.get_raw_by_name("Product", schema)
.ok_or("Product not found in .build.info")?;
let version = row
.get_raw_by_name("Version", schema)
.ok_or("Version not found in .build.info")?;
let branch = row
.get_raw_by_name("Branch", schema)
.ok_or("Branch not found in .build.info")?;
let build_key = row
.get_raw_by_name("Build Key", schema)
.ok_or("Build Key not found in .build.info")?;
let cdn_path = row
.get_raw_by_name("CDN Path", schema)
.ok_or("CDN Path not found in .build.info")?;
let cdn_hosts_str = row
.get_raw_by_name("CDN Hosts", schema)
.ok_or("CDN Hosts not found in .build.info")?;
let cdn_hosts: Vec<&str> = cdn_hosts_str.split_whitespace().collect();
let cdn_host = cdn_hosts.first().ok_or("No CDN hosts available")?;
info!("🔄 Resuming installation:");
info!(" • Product: {}", product);
info!(" • Version: {}", version);
info!(" • Branch: {}", branch);
info!(" • Build Key: {}", build_key);
info!(" • CDN Host: {}", cdn_host);
let build_config_subdir = format!("{}/{}", &build_key[0..2], &build_key[2..4]);
let build_config_path = install_path
.join("Data/config")
.join(&build_config_subdir)
.join(build_key);
if !build_config_path.exists() {
return Err(format!(
"Build configuration not found at: {}. The installation appears corrupted.",
build_config_path.display()
)
.into());
}
let build_config_data = tokio::fs::read_to_string(&build_config_path).await?;
let build_config = tact_parser::config::BuildConfig::parse(&build_config_data)?;
info!("✓ Loaded build configuration from local cache");
let encoding_value = build_config
.config
.get_value("encoding")
.ok_or("Missing encoding field in build config")?;
let encoding_parts: Vec<&str> = encoding_value.split_whitespace().collect();
let encoding_ekey = if encoding_parts.len() >= 2 {
encoding_parts[1]
} else {
encoding_parts[0]
};
info!("📥 Downloading encoding file...");
let cdn_client = CachedCdnClient::new().await?;
cdn_client.add_primary_hosts(cdn_hosts.iter().map(|h| h.to_string()));
cdn_client.add_fallback_host("cdn.arctium.tools");
cdn_client.add_fallback_host("tact.mirror.reliquaryhq.com");
let encoding_data = cdn_client
.download_data(cdn_hosts[0], cdn_path, encoding_ekey)
.await?
.bytes()
.await?;
let encoding_data = if encoding_data.starts_with(b"BLTE") {
blte::decompress_blte(encoding_data.to_vec(), None)?
} else {
encoding_data.to_vec()
};
let encoding_file = EncodingFile::parse(&encoding_data)?;
info!("✓ Encoding file loaded");
let archive_index = ArchiveIndex::new();
info!("📦 Using empty archive index for resume (loose file fallback)");
let install_value = build_config
.config
.get_value("install")
.ok_or("Missing install field in build config")?;
let install_parts: Vec<&str> = install_value.split_whitespace().collect();
let install_ekey = if install_parts.len() >= 2 {
install_parts[1].to_string()
} else {
let ckey = install_parts[0];
let ekey_bytes = encoding_file
.lookup_by_ckey(&hex::decode(ckey)?)
.and_then(|e| e.encoding_keys.first())
.ok_or("Install manifest encoding key not found")?;
hex::encode(ekey_bytes)
};
info!("📥 Downloading install manifest...");
let install_data = cdn_client
.download_data(cdn_hosts[0], cdn_path, &install_ekey)
.await?
.bytes()
.await?;
let install_data = if install_data.starts_with(b"BLTE") {
blte::decompress_blte(install_data.to_vec(), None)?
} else {
install_data.to_vec()
};
let install_manifest = InstallManifest::parse(&install_data)?;
info!(
"📋 Install manifest loaded: {} files",
install_manifest.entries.len()
);
let data_dir = install_path.join("Data/data");
tokio::fs::create_dir_all(&data_dir).await?;
let mut missing_files = Vec::new();
let mut total_missing_size = 0u64;
info!("🔍 Checking for missing files...");
for entry in &install_manifest.entries {
if let Some(encoding_entry) = encoding_file.lookup_by_ckey(&entry.ckey) {
if let Some(ekey) = encoding_entry.encoding_keys.first() {
let ekey_hex = hex::encode(ekey);
let expected_path = data_dir.join(&ekey_hex);
if !expected_path.exists() {
missing_files.push((entry, ekey_hex));
total_missing_size += entry.size as u64;
}
}
}
}
if missing_files.is_empty() {
info!("✅ No missing files found - installation is complete!");
return Ok(());
}
info!(
"📊 Found {} missing files ({} total)",
missing_files.len(),
format_bytes(total_missing_size)
);
info!("📥 Downloading missing files...");
let mut downloaded_count = 0;
let mut error_count = 0;
for (entry, ekey_hex) in &missing_files {
match download_file_with_archive(
&cdn_client,
&archive_index,
cdn_hosts[0],
cdn_path,
ekey_hex,
)
.await
{
Ok(data) => {
let file_path = data_dir.join(ekey_hex);
if let Err(e) = tokio::fs::write(&file_path, &data).await {
warn!("Failed to write {}: {}", entry.path, e);
error_count += 1;
} else {
downloaded_count += 1;
if downloaded_count % 10 == 0 {
info!(
"📥 Downloaded {}/{} files...",
downloaded_count,
missing_files.len()
);
}
}
}
Err(e) => {
warn!("Failed to fetch {}: {}", entry.path, e);
error_count += 1;
}
}
}
info!(
"✅ Resume completed: {} files downloaded, {} errors",
downloaded_count, error_count
);
Ok(())
}
async fn handle_repair_installation(
install_path: PathBuf,
verify_checksums: bool,
dry_run: bool,
_format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
info!("🔧 Starting repair of installation at {:?}", install_path);
if dry_run {
info!("🔍 DRY RUN mode - no files will be modified");
}
let build_info_path = install_path.join(".build.info");
if !build_info_path.exists() {
return Err(format!(
"No .build.info found at {}. This does not appear to be a valid installation.",
install_path.display()
)
.into());
}
if verify_checksums {
info!("🔍 Verifying file checksums...");
info!("🚧 Checksum verification not yet implemented");
}
info!("🔍 Checking for missing or corrupted files...");
if dry_run {
info!("✅ Dry run completed - repair functionality in development");
} else {
info!("🚧 Repair functionality implementation in progress");
info!(
"💡 Use 'ngdp install game <product> --path {} --resume' for now",
install_path.display()
);
}
Ok(())
}