feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+574
View File
@@ -0,0 +1,574 @@
//! Official JP download inventory extraction.
use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
use std::collections::{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<String>,
/// Table file names from `TableCatalog.bytes`.
pub table_file_names: Vec<String>,
/// Media file names from `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
/// 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<String>,
/// Media file names from this platform's `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
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_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
}
}
/// Returns this platform's official bundle patch-pack URLs.
pub fn bundle_patch_pack_urls(
&self,
root: &YostarJpResourceRoot,
) -> Result<Vec<String>, 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<Vec<String>, 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<String>,
/// Per-platform bundle/media inventories.
pub platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
}
impl YostarJpPlatformDownloadInventory {
/// Extracts a multi-platform inventory from official JP catalog bytes.
pub fn from_catalog_bytes(
table_catalog: &[u8],
platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
) -> Self {
Self {
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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_file_names(table_catalog, &["db", "zip"]),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
}
}
/// Returns the official bundle patch-pack URLs for a platform.
pub fn bundle_patch_pack_urls(
&self,
root: &YostarJpResourceRoot,
platform: PatchPlatform,
) -> Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, String> {
self.direct_download_urls_for_platforms(root, &verified_official_platforms())
}
}
fn merge_platform_catalogs(
catalogs: Vec<YostarJpPlatformCatalogInventory>,
) -> Vec<YostarJpPlatformCatalogInventory> {
let mut merged = Vec::<YostarJpPlatformCatalogInventory>::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<String>, names: Vec<String>) {
let mut merged = existing.iter().cloned().collect::<BTreeSet<_>>();
merged.extend(names);
*existing = merged.into_iter().collect();
}
fn extract_full_patch_pack_names(data: &[u8]) -> Vec<String> {
extract_file_names(data, &["zip"]) // only .zip names survive here
.into_iter()
.filter(|name| is_full_patch_pack_name(name))
.collect()
}
fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
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_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
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(&current).into_owned());
current.clear();
} else {
current.clear();
}
}
if current.len() >= min_len {
strings.push(String::from_utf8_lossy(&current).into_owned());
}
strings
}
fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
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 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 unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms
.iter()
.copied()
.fold(Vec::new(), |mut unique, platform| {
if !unique.contains(&platform) {
unique.push(platform);
}
unique
})
}
fn append_unique_urls(urls: &mut Vec<String>, seen: &mut HashSet<String>, next_urls: Vec<String>) {
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\0rawdata/table/excel/ignored.bytes\0Battle.zip8",
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_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!["JP_Airi.zip".to_string(), "JP_Akane.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 Battle.zip",
b"JP_Airi.zip 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/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",
b"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/JP_Airi.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip")));
}
#[test]
fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() {
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
b"ExcelDB.db",
vec![
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"JP_Airi_Win.zip",
),
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android,
b"FullPatch_001.zip",
b"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/JP_Airi_Win.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/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]
#[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_eq!(inventory.table_file_names.len(), 6351);
assert_eq!(inventory.media_file_names.len(), 1887);
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 == "JP_Airi.zip"));
}
}