//! Official JP download inventory extraction. use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot}; use std::collections::{BTreeMap, BTreeSet, HashSet}; /// Download inventory extracted from the official JP catalog bytes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct YostarJpDownloadInventory { /// Patch-pack zip names from `BundlePackingInfo.bytes`. pub bundle_patch_pack_names: Vec, /// Table file names from `TableCatalog.bytes`. pub table_file_names: Vec, /// Media file relative paths from `MediaCatalog.bytes`. pub media_file_names: Vec, } /// Platform-specific catalog inventory extracted from official JP bytes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct YostarJpPlatformCatalogInventory { /// Platform this inventory belongs to. pub platform: PatchPlatform, /// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`. pub bundle_patch_pack_names: Vec, /// Media file relative paths from this platform's `MediaCatalog.bytes`. pub media_file_names: Vec, } impl YostarJpPlatformCatalogInventory { /// Extracts one platform inventory from official JP catalog bytes. pub fn from_catalog_bytes( platform: PatchPlatform, bundle_packing_info: &[u8], media_catalog: &[u8], ) -> Self { Self { platform, bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), media_file_names: extract_media_file_paths(media_catalog), } } /// Returns this platform's official bundle patch-pack URLs. pub fn bundle_patch_pack_urls( &self, root: &YostarJpResourceRoot, ) -> Result, String> { self.bundle_patch_pack_names .iter() .map(|name| root.bundle_patch_pack(self.platform, name)) .collect() } /// Returns this platform's official media URLs. pub fn media_file_urls(&self, root: &YostarJpResourceRoot) -> Result, String> { self.media_file_names .iter() .map(|name| root.media_file(self.platform, name)) .collect() } } /// Multi-platform download inventory with shared table files and platform /// specific patch-pack/media catalogs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct YostarJpPlatformDownloadInventory { /// Table file names from the shared `TableCatalog.bytes`. pub table_file_names: Vec, /// Per-platform bundle/media inventories. pub platform_catalogs: Vec, } impl YostarJpPlatformDownloadInventory { /// Extracts a multi-platform inventory from official JP catalog bytes. pub fn from_catalog_bytes( table_catalog: &[u8], platform_catalogs: Vec, ) -> Self { Self { table_file_names: extract_table_file_names(table_catalog), platform_catalogs: merge_platform_catalogs(platform_catalogs), } } /// Builds a platform inventory by reusing a legacy shared inventory for /// each requested platform. pub fn from_shared_inventory( inventory: YostarJpDownloadInventory, platforms: &[PatchPlatform], ) -> Self { let platform_catalogs = unique_platforms(platforms) .into_iter() .map(|platform| YostarJpPlatformCatalogInventory { platform, bundle_patch_pack_names: inventory.bundle_patch_pack_names.clone(), media_file_names: inventory.media_file_names.clone(), }) .collect(); Self { table_file_names: inventory.table_file_names, platform_catalogs, } } /// Returns the official table file URLs. pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result, String> { self.table_file_names .iter() .map(|name| root.table_bundle(name)) .collect() } /// Returns the complete direct-download URL set for multiple platforms. /// /// Table bundles are emitted once. Patch-pack and media files are emitted /// from the matching platform catalog only, which avoids creating invalid /// cross-platform URL combinations. pub fn direct_download_urls_for_platforms( &self, root: &YostarJpResourceRoot, platforms: &[PatchPlatform], ) -> Result, String> { let mut urls = Vec::new(); let mut seen = HashSet::new(); append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?); for platform in unique_platforms(platforms) { let catalog = self.platform_catalog(platform).ok_or_else(|| { format!( "Missing official catalog inventory for platform: {}", platform.as_str() ) })?; append_unique_urls(&mut urls, &mut seen, catalog.bundle_patch_pack_urls(root)?); append_unique_urls(&mut urls, &mut seen, catalog.media_file_urls(root)?); } Ok(urls) } /// Returns the complete direct-download URL set for all verified official /// JP platforms. pub fn direct_download_urls_for_verified_platforms( &self, root: &YostarJpResourceRoot, ) -> Result, String> { self.direct_download_urls_for_platforms(root, &verified_official_platforms()) } fn platform_catalog( &self, platform: PatchPlatform, ) -> Option<&YostarJpPlatformCatalogInventory> { self.platform_catalogs .iter() .find(|catalog| catalog.platform == platform) } } impl YostarJpDownloadInventory { /// Extracts an inventory from the three official JP catalog bytes. pub fn from_catalog_bytes( bundle_packing_info: &[u8], table_catalog: &[u8], media_catalog: &[u8], ) -> Self { Self { bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), table_file_names: extract_table_file_names(table_catalog), media_file_names: extract_media_file_paths(media_catalog), } } /// Returns the official bundle patch-pack URLs for a platform. pub fn bundle_patch_pack_urls( &self, root: &YostarJpResourceRoot, platform: PatchPlatform, ) -> Result, String> { self.bundle_patch_pack_names .iter() .map(|name| root.bundle_patch_pack(platform, name)) .collect() } /// Returns the official table file URLs. pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result, String> { self.table_file_names .iter() .map(|name| root.table_bundle(name)) .collect() } /// Returns the official media file URLs for a platform. pub fn media_file_urls( &self, root: &YostarJpResourceRoot, platform: PatchPlatform, ) -> Result, String> { self.media_file_names .iter() .map(|name| root.media_file(platform, name)) .collect() } /// Returns the complete direct-download URL set for a platform. pub fn direct_download_urls( &self, root: &YostarJpResourceRoot, platform: PatchPlatform, ) -> Result, String> { let mut urls = self.bundle_patch_pack_urls(root, platform)?; urls.extend(self.table_file_urls(root)?); urls.extend(self.media_file_urls(root, platform)?); Ok(urls) } /// Returns the complete direct-download URL set for multiple platforms. /// /// Table bundles are emitted once. Platform-specific URLs are emitted in /// platform order and deduplicated by URL so shared Android media paths /// only appear once. pub fn direct_download_urls_for_platforms( &self, root: &YostarJpResourceRoot, platforms: &[PatchPlatform], ) -> Result, String> { let mut urls = Vec::new(); let mut seen = HashSet::new(); append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?); for platform in unique_platforms(platforms) { append_unique_urls( &mut urls, &mut seen, self.bundle_patch_pack_urls(root, platform)?, ); append_unique_urls(&mut urls, &mut seen, self.media_file_urls(root, platform)?); } Ok(urls) } /// Returns the complete direct-download URL set for all verified official /// JP platforms. pub fn direct_download_urls_for_verified_platforms( &self, root: &YostarJpResourceRoot, ) -> Result, String> { self.direct_download_urls_for_platforms(root, &verified_official_platforms()) } } fn merge_platform_catalogs( catalogs: Vec, ) -> Vec { let mut merged = Vec::::new(); for catalog in catalogs { if let Some(existing) = merged .iter_mut() .find(|existing| existing.platform == catalog.platform) { merge_names( &mut existing.bundle_patch_pack_names, catalog.bundle_patch_pack_names, ); merge_names(&mut existing.media_file_names, catalog.media_file_names); } else { merged.push(catalog); } } merged.sort_by_key(|catalog| catalog.platform); merged } fn merge_names(existing: &mut Vec, names: Vec) { let mut merged = existing.iter().cloned().collect::>(); merged.extend(names); *existing = merged.into_iter().collect(); } fn extract_full_patch_pack_names(data: &[u8]) -> Vec { extract_file_names(data, &["zip"]) // only .zip names survive here .into_iter() .filter(|name| is_full_patch_pack_name(name)) .collect() } fn extract_table_file_names(data: &[u8]) -> Vec { let mut counts = BTreeMap::::new(); // Real TableCatalog bytes list top-level downloadable table files twice, // while package-internal stage/resource .zip names normally appear once // next to rawdata paths and are not direct TableBundles URLs. for string in extract_printable_strings(data, 4) { for name in candidate_file_names(&string, &["db", "zip"]) { *counts.entry(name).or_default() += 1; } } counts .into_iter() .filter_map(|(name, count)| (count >= 2).then_some(name)) .collect() } fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec { let mut names = BTreeSet::new(); for string in extract_printable_strings(data, 4) { for name in candidate_file_names(&string, extensions) { names.insert(name); } } names.into_iter().collect() } fn extract_media_file_paths(data: &[u8]) -> Vec { let mut paths = BTreeSet::new(); for string in extract_printable_strings(data, 4) { for path in candidate_relative_paths(&string, &["zip", "mp4", "png", "jpg", "jpeg", "ogg", "wav"]) { paths.insert(path); } } paths.into_iter().collect() } fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec { let mut strings = Vec::new(); let mut current = Vec::new(); for &byte in data { if byte.is_ascii_graphic() || byte == b' ' { current.push(byte); } else if current.len() >= min_len { strings.push(String::from_utf8_lossy(¤t).into_owned()); current.clear(); } else { current.clear(); } } if current.len() >= min_len { strings.push(String::from_utf8_lossy(¤t).into_owned()); } strings } fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec { let mut names = Vec::new(); let bytes = value.as_bytes(); for extension in extensions { let suffix = format!(".{extension}"); let mut search_from = 0; while let Some(relative_index) = value[search_from..].find(&suffix) { let extension_start = search_from + relative_index; let start = filename_start(bytes, extension_start); let end = extension_start + suffix.len(); let candidate = &value[start..end]; let candidate = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate); if is_plausible_file_name(candidate) { names.push(candidate.to_string()); } search_from = end; } } names } fn candidate_relative_paths(value: &str, extensions: &[&str]) -> Vec { let mut paths = Vec::new(); let bytes = value.as_bytes(); for extension in extensions { let suffix = format!(".{extension}"); let mut search_from = 0; while let Some(relative_index) = value[search_from..].find(&suffix) { let extension_start = search_from + relative_index; let start = filename_start(bytes, extension_start); let end = extension_start + suffix.len(); let candidate = &value[start..end]; let candidate = candidate.replace('\\', "/"); if is_plausible_relative_path(&candidate) { paths.push(candidate); } search_from = end; } } paths } fn filename_start(bytes: &[u8], mut index: usize) -> usize { while index > 0 { let byte = bytes[index - 1]; if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b'\\') { index -= 1; } else { break; } } index } fn is_full_patch_pack_name(name: &str) -> bool { let bytes = name.as_bytes(); if bytes.len() != 17 || !name.starts_with("FullPatch_") || !name.ends_with(".zip") { return false; } bytes[10..13].iter().all(|byte| byte.is_ascii_digit()) } fn is_plausible_file_name(name: &str) -> bool { !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains("..") && !name.contains(':') && !name.contains('=') && name .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) } fn is_plausible_relative_path(path: &str) -> bool { if path.is_empty() || path.starts_with('/') || path.starts_with('.') || path.contains("..") || path.contains(':') || path.contains('=') || !path.contains('/') { return false; } path.split('/').all(|segment| { !segment.is_empty() && segment != "." && segment != ".." && segment .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) }) } fn unique_platforms(platforms: &[PatchPlatform]) -> Vec { platforms .iter() .copied() .fold(Vec::new(), |mut unique, platform| { if !unique.contains(&platform) { unique.push(platform); } unique }) } fn append_unique_urls(urls: &mut Vec, seen: &mut HashSet, next_urls: Vec) { for url in next_urls { if seen.insert(url.clone()) { urls.push(url); } } } #[cfg(test)] mod tests { use super::*; use crate::official::yostar_jp::YostarJpResourceRoot; const ROOT: &str = "r93_dctuo3tcd029wwxnvb55"; #[test] fn extracts_download_names_from_synthetic_bytes() { let inventory = YostarJpDownloadInventory::from_catalog_bytes( b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix", b"GameData\\Table\\ExcelDB.db\0ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip\0Battle.zip8", b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0audio/voc_jp/jp_akane/jp_akane\0GameData\\Audio\\VOC_JP\\JP_Akane.zip", ); assert_eq!( inventory.bundle_patch_pack_names, vec![ "FullPatch_000.zip".to_string(), "FullPatch_114.zip".to_string() ] ); assert_eq!( inventory.table_file_names, vec!["Battle.zip".to_string(), "ExcelDB.db".to_string(),] ); assert_eq!( inventory.media_file_names, vec![ "GameData/Audio/VOC_JP/JP_Airi.zip".to_string(), "GameData/Audio/VOC_JP/JP_Akane.zip".to_string(), ] ); } #[test] fn extracts_only_top_level_table_files_from_table_catalog() { let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes( b"ExcelDB.db\0ExcelDB.db\0TablePatchPack_Prologue_GroundStage_1.zip\0TablePatchPack_Prologue_GroundStage_1.zip|1011101_01_s1_01_mainstreet_p01_d.zip\0rawdata/ground/stage/bytes/1011101_01_s1_01_mainstreet_p01_d.bytes", Vec::new(), ); assert_eq!( inventory.table_file_names, vec![ "ExcelDB.db".to_string(), "TablePatchPack_Prologue_GroundStage_1.zip".to_string() ] ); } #[test] fn builds_direct_download_urls() { let inventory = YostarJpDownloadInventory::from_catalog_bytes( b"FullPatch_000.zip FullPatch_001.zip", b"ExcelDB.db ExcelDB.db Battle.zip Battle.zip", b"GameData\\Audio\\VOC_JP\\JP_Airi.zip GameData\\Audio\\VOC_JP\\JP_Akane.zip", ); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); let urls = inventory .direct_download_urls(&root, PatchPlatform::Windows) .unwrap(); assert_eq!(urls[0], "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/FullPatch_000.zip"); assert!(urls .iter() .any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); assert!(urls .iter() .any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip"))); } #[test] fn builds_direct_download_urls_for_verified_platforms() { let inventory = YostarJpDownloadInventory::from_catalog_bytes( b"FullPatch_000.zip", b"ExcelDB.db ExcelDB.db", b"GameData\\Audio\\VOC_JP\\JP_Airi.zip", ); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); let urls = inventory .direct_download_urls_for_verified_platforms(&root) .unwrap(); assert_eq!(urls.len(), 5); assert!(urls .iter() .any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip"))); assert!(urls .iter() .any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip"))); assert!(urls .iter() .any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); assert!(urls .iter() .any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip"))); assert!(urls .iter() .any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip"))); } #[test] fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() { let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes( b"ExcelDB.db ExcelDB.db", vec![ YostarJpPlatformCatalogInventory::from_catalog_bytes( PatchPlatform::Windows, b"FullPatch_000.zip", b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip", ), YostarJpPlatformCatalogInventory::from_catalog_bytes( PatchPlatform::Android, b"FullPatch_001.zip", b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip", ), ], ); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); let urls = inventory .direct_download_urls_for_verified_platforms(&root) .unwrap(); assert!(urls .iter() .any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip"))); assert!(urls .iter() .any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip"))); assert!(urls .iter() .any(|url| url .ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi_Win.zip"))); assert!(urls .iter() .any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi_Android.zip"))); assert!(!urls .iter() .any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip"))); assert!(!urls .iter() .any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip"))); } #[test] fn media_catalog_uses_download_relative_path_not_leaf_name() { let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes( b"ExcelDB.db ExcelDB.db", vec![YostarJpPlatformCatalogInventory::from_catalog_bytes( PatchPlatform::Windows, b"FullPatch_000.zip", b"scenario/event/10000_title_sound\0Prologue\\Scenario\\Event\\10000_Title_Sound.ogg\0 10000_Title_Sound.ogg", )], ); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); let urls = inventory .direct_download_urls_for_platforms(&root, &[PatchPlatform::Windows]) .unwrap(); assert!(urls.iter().any(|url| { url.ends_with("/MediaResources-Windows/Prologue/Scenario/Event/10000_Title_Sound.ogg") })); assert!(!urls .iter() .any(|url| url.ends_with("/MediaResources-Windows/10000_Title_Sound.ogg"))); } #[test] #[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"] fn extracts_realistic_counts_from_official_shape() { let bundle_packing_info = std::fs::read(std::env::var("BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO").unwrap()).unwrap(); let table_catalog = std::fs::read(std::env::var("BAT_REAL_OFFICIAL_TABLE_CATALOG").unwrap()).unwrap(); let media_catalog = std::fs::read(std::env::var("BAT_REAL_OFFICIAL_MEDIA_CATALOG").unwrap()).unwrap(); let inventory = YostarJpDownloadInventory::from_catalog_bytes( &bundle_packing_info, &table_catalog, &media_catalog, ); assert_eq!(inventory.bundle_patch_pack_names.len(), 142); assert!(inventory.table_file_names.len() < 1000); assert!(inventory.media_file_names.len() >= 4000); assert!(inventory .bundle_patch_pack_names .iter() .any(|name| name == "FullPatch_000.zip")); assert!(inventory .table_file_names .iter() .any(|name| name == "ExcelDB.db")); assert!(inventory .media_file_names .iter() .any(|name| name == "GameData/Audio/VOC_JP/JP_Airi.zip")); assert!(inventory .media_file_names .iter() .any(|name| name.ends_with(".jpg"))); } }