feat(assetbundle): 完善官方资源解析与双目录发布

This commit is contained in:
2026-07-25 20:51:01 +08:00
parent 102b49b666
commit 3e9bb20d79
37 changed files with 4663 additions and 1333 deletions
+1
View File
@@ -12,6 +12,7 @@ path = "src/bin/bat_official_sync.rs"
[dependencies]
bat-core = { path = "../core" }
bat-adapters = { path = "../adapters" }
bat-assetbundle = { path = "../crates/bat-assetbundle" }
bat-cas-engine = { path = "../crates/bat-cas-engine" }
anyhow.workspace = true
thiserror.workspace = true
+94 -13
View File
@@ -52,7 +52,7 @@ const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00";
const STARTUP_BANNER: &str = r#"
============================================================
=====================================================================================
____ _ _ _ _ _____ _ _ _ _
| __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_
| _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __|
@@ -61,7 +61,7 @@ const STARTUP_BANNER: &str = r#"
BlueArchiveToolkit
Official Resource Sync
============================================================
=====================================================================================
"#;
fn main() {
@@ -3104,6 +3104,7 @@ fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&
&& options.config.launcher_version == defaults.launcher_version
&& options.config.platforms.is_none()
&& options.config.output_root == defaults.output_root
&& options.config.localized_output_root == defaults.localized_output_root
&& options.config.snapshot_path.is_none()
&& options.config.curl_command == defaults.curl_command
&& options.config.curl_proxy == defaults.curl_proxy
@@ -3122,6 +3123,7 @@ fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&
}
}
#[cfg(test)]
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH)
}
@@ -3456,7 +3458,14 @@ impl HumanReport for OfficialUpdateReport {
print_field("本地审计", format_bool(self.audit_local));
print_field("自动修复", format_bool(self.repair));
print_field("dry-run", format_bool(self.dry_run));
print_path_field("输出根目录", &self.output_root);
print_path_field("官方资源目录", &self.output_root);
print_path_field("汉化输出目录", &self.localized_output_root);
print_field("汉化发布状态", self.localized_release_status.as_str());
print_path_field("汉化 current", &self.localized_current_path);
print_optional_path_field(
"汉化 published",
self.localized_published_version_path.as_ref(),
);
print_path_field("active release", &self.active_resource_root);
print_path_field("current", &self.current_path);
print_path_field("version state", &self.version_state_path);
@@ -3464,6 +3473,7 @@ impl HumanReport for OfficialUpdateReport {
print_optional_path_field("published", self.published_version_path.as_ref());
print_path_field("snapshot", &self.snapshot_path);
print_path_field("manifest", &self.download_manifest);
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref());
print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool));
@@ -3478,6 +3488,14 @@ impl HumanReport for OfficialUpdateReport {
print_field("需修复", self.local_manifest_repair_needed_count);
print_field("官方 hash 校验", self.official_seed_hash_verified_count);
print_verification_summary(&self.verification_summary);
if let Some(summary) = self.parse_summary.as_ref() {
print_field("解析缓存条目", summary.cache_entry_count);
print_field("解析成功 bundle", summary.parsed_bundle_count);
print_field("解析复用", summary.skipped_unchanged_count);
print_field("解析不支持", summary.unsupported_count);
print_field("解析失败", summary.failed_count);
print_field("TextAsset", summary.text_asset_count);
}
print_field("catalog marker", self.addressables_marker_checked_count);
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
print_list("计划 URL", &self.download_urls, 8);
@@ -3868,11 +3886,21 @@ fn build_doctor_report(
let mut checks = vec![
path_check("state_dir", state_dir, "后台状态目录可用"),
path_check("output_root", &config.output_root, "资源输出目录可用"),
path_check(
"localized_output_root",
&config.localized_output_root,
"汉化输出目录可用",
),
safety_check(
"output_root_safety",
validate_output_root(&config.output_root),
"资源输出目录安全边界通过",
),
safety_check(
"localized_output_root_safety",
validate_output_root(&config.localized_output_root),
"汉化输出目录安全边界通过",
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(state_dir),
@@ -4524,6 +4552,8 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
}
args.push("--output".to_string());
args.push(config.output_root.to_string_lossy().to_string());
args.push("--localized-output".to_string());
args.push(config.localized_output_root.to_string_lossy().to_string());
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
args.push("--snapshot".to_string());
args.push(snapshot_path.to_string_lossy().to_string());
@@ -4937,6 +4967,7 @@ fn stage_to_static(stage: &str) -> &'static str {
"download" => "download",
"snapshot" => "snapshot",
"publish" => "publish",
"parse" => "parse",
"finish" => "finish",
"watch" => "watch",
"daemon" => "daemon",
@@ -4968,6 +4999,7 @@ fn localized_stage(stage: &str) -> &str {
"dry-run" => "试运行",
"download" => "下载",
"publish" => "发布",
"parse" => "解析",
"resource" => "资源",
"finish" => "完成",
"watch" => "常驻",
@@ -5003,8 +5035,10 @@ const ENV_TEMPLATE: &str = r#"# BlueArchive Toolkit 配置文件(bat 首次启
# BAT_SKIP_ENV_FILE=1 bat
# ---- ----
# ./bat-resources
# ./bat-resources
BAT_OUTPUT=./bat-resources
# ./bat-localized
BAT_LOCALIZED_OUTPUT=./bat-localized
# app-version / connection-group / server-info 1
BAT_AUTO_DISCOVER=1
# bat.sock / / /tmp/bat-pid
@@ -5175,6 +5209,9 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_OUTPUT") {
options.config.output_root = PathBuf::from(v);
}
if let Some(v) = value("BAT_LOCALIZED_OUTPUT") {
options.config.localized_output_root = PathBuf::from(v);
}
if let Some(v) = value("BAT_STATE_DIR") {
options.state_dir = PathBuf::from(v);
}
@@ -5343,6 +5380,11 @@ fn parse_args_with_env(
options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?);
options.output_explicit = true;
}
"--localized-output" => {
options.config.localized_output_root =
PathBuf::from(next_option_value(&mut args, &flag)?);
options.output_explicit = true;
}
"--state-dir" | "--pid-dir" => {
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
}
@@ -5707,7 +5749,10 @@ fn print_usage(binary: &str) {
eprintln!("Sync:");
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
eprintln!(
" --output <DIR> Resource publish root (default: ./bat-resources)"
" --output <DIR> Official resource publish root (default: ./bat-resources)"
);
eprintln!(
" --localized-output <DIR> Localized output root (default: ./bat-localized)"
);
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
eprintln!(" --curl <PATH> curl executable (default: curl)");
@@ -5739,7 +5784,10 @@ fn print_usage(binary: &str) {
eprintln!();
eprintln!("Defaults:");
eprintln!(" platforms: Windows,Android");
eprintln!(" resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)");
eprintln!(
" official resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)"
);
eprintln!(" localized output: ./bat-localized (separate patch/export target)");
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)");
eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}");
}
@@ -5820,6 +5868,7 @@ mod tests {
&["bat"],
&[
("BAT_OUTPUT", "/srv/bat"),
("BAT_LOCALIZED_OUTPUT", "/srv/bat-localized"),
("BAT_AUTO_DISCOVER", "1"),
("BAT_STATE_DIR", "/srv/state"),
("BAT_INTERVAL_SECONDS", "120"),
@@ -5827,6 +5876,10 @@ mod tests {
)
.unwrap();
assert_eq!(options.config.output_root, PathBuf::from("/srv/bat"));
assert_eq!(
options.config.localized_output_root,
PathBuf::from("/srv/bat-localized")
);
assert!(options.config.auto_discover);
assert_eq!(options.state_dir, PathBuf::from("/srv/state"));
assert_eq!(options.interval, Duration::from_secs(120));
@@ -5946,6 +5999,7 @@ mod tests {
keys.push(key);
}
assert!(keys.contains(&"BAT_OUTPUT".to_string()));
assert!(keys.contains(&"BAT_LOCALIZED_OUTPUT".to_string()));
assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string()));
}
@@ -6118,6 +6172,10 @@ mod tests {
assert!(options.banner);
assert_eq!(options.output_format, OutputFormat::Human);
assert_eq!(config.output_root, PathBuf::from("./bat-resources"));
assert_eq!(
config.localized_output_root,
PathBuf::from("./bat-localized")
);
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
}
@@ -6210,6 +6268,8 @@ mod tests {
"--daemon",
"--output",
"/tmp/daemon-output",
"--localized-output",
"/tmp/daemon-localized",
"--interval",
"30m",
])
@@ -6222,6 +6282,10 @@ mod tests {
options.config.output_root,
PathBuf::from("/tmp/daemon-output")
);
assert_eq!(
options.config.localized_output_root,
PathBuf::from("/tmp/daemon-localized")
);
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
assert_eq!(options.interval, Duration::from_secs(30 * 60));
assert!(options.quiet_up_to_date);
@@ -6246,11 +6310,19 @@ mod tests {
assert!(!status.progress);
assert!(!status.banner);
assert_eq!(status.config.output_root, PathBuf::from("./bat-resources"));
assert_eq!(
status.config.localized_output_root,
PathBuf::from("./bat-localized")
);
assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap();
assert_eq!(stop.command, CliCommand::Stop);
assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources"));
assert_eq!(
stop.config.localized_output_root,
PathBuf::from("./bat-localized")
);
assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid"));
}
@@ -6312,6 +6384,8 @@ mod tests {
"Windows,Android",
"--output",
"/tmp/daemon-output",
"--localized-output",
"/tmp/daemon-localized",
"--state-dir",
"/tmp/daemon-state",
"--curl",
@@ -6336,6 +6410,9 @@ mod tests {
assert!(args
.windows(2)
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--state-dir", "/tmp/daemon-state"]));
@@ -6579,8 +6656,10 @@ mod tests {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
let mut base_config = OfficialUpdateConfig::default();
base_config.output_root = output_root;
let base_config = OfficialUpdateConfig {
output_root,
..Default::default()
};
let (queue, _rx) = mpsc::channel::<TaskJob>();
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
@@ -6662,11 +6741,13 @@ mod tests {
let control = new_daemon_control();
// 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。
let (queue, rx) = mpsc::channel::<TaskJob>();
let mut base_config = OfficialUpdateConfig::default();
base_config.force = true;
base_config.dry_run = true;
base_config.audit_local = false;
base_config.repair = false;
let base_config = OfficialUpdateConfig {
force: true,
dry_run: true,
audit_local: false,
repair: false,
..Default::default()
};
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
+156
View File
@@ -87,6 +87,16 @@ pub struct UnityFsImportSummary {
pub directory_count: usize,
/// UnityFS directory 路径。
pub directories: Vec<String>,
/// UnityFS directory 解出的文件数量。
pub file_count: usize,
/// 成功解析出的 Unity serialized file 数量。
pub serialized_file_count: usize,
/// 成功解析出的 TextAsset 数量。
pub text_asset_count: usize,
/// TextAsset 名称列表。
pub text_assets: Vec<String>,
/// 非致命 serialized-file 解析诊断数量。
pub serialized_parse_error_count: usize,
}
/// Manifest 导入报告。
@@ -266,6 +276,15 @@ impl<'a> ResourceImportService<'a> {
.into_iter()
.map(|directory| directory.path)
.collect(),
file_count: parsed.files.len(),
serialized_file_count: parsed.serialized_files.len(),
text_asset_count: parsed.text_assets.len(),
text_assets: parsed
.text_assets
.into_iter()
.map(|asset| asset.name)
.collect(),
serialized_parse_error_count: parsed.serialized_parse_errors.len(),
})
}
}
@@ -375,6 +394,26 @@ mod tests {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
@@ -414,6 +453,83 @@ mod tests {
data
}
fn synthetic_text_asset_unityfs_bundle() -> Vec<u8> {
let serialized_file = synthetic_serialized_text_asset();
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[1; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, serialized_file.len() as u32);
push_u32(&mut blocks_info, serialized_file.len() as u32);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, serialized_file.len() as u64);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-scenario");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, 16);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(&serialized_file);
let total_size = data.len() as u64;
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn synthetic_serialized_text_asset() -> Vec<u8> {
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 8);
object_data.extend_from_slice(b"Scenario");
align(&mut object_data, 4);
push_u32_le(&mut object_data, 15);
object_data.extend_from_slice("こんにちは".as_bytes());
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2021.3.56f2\0");
push_i32_le(&mut metadata, 19);
metadata.push(0);
push_i32_le(&mut metadata, 1);
push_i32_le(&mut metadata, 49);
metadata.push(0);
push_i16_le(&mut metadata, 0);
metadata.extend_from_slice(&[0; 16]);
push_i32_le(&mut metadata, 1);
align(&mut metadata, 4);
push_i64_le(&mut metadata, 1);
push_u64_le(&mut metadata, 0);
push_u32_le(&mut metadata, object_data.len() as u32);
push_i32_le(&mut metadata, 0);
let header_len = 48usize;
let data_offset = header_len + metadata.len();
let file_size = data_offset + object_data.len();
let mut file = Vec::new();
push_u32(&mut file, metadata.len() as u32);
push_u32(&mut file, file_size as u32);
push_u32(&mut file, 22);
push_u32(&mut file, 0);
file.push(0);
file.extend_from_slice(&[0, 0, 0]);
push_u32(&mut file, metadata.len() as u32);
push_u64(&mut file, file_size as u64);
push_u64(&mut file, data_offset as u64);
push_u64(&mut file, 0);
file.extend_from_slice(&metadata);
file.extend_from_slice(&object_data);
file
}
fn synthetic_manifest() -> GenericManifest {
GenericManifest {
format: ManifestFormat::AddressablesCatalog,
@@ -594,6 +710,11 @@ mod tests {
assert_eq!(unityfs.block_count, 1);
assert_eq!(unityfs.directory_count, 1);
assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]);
assert_eq!(unityfs.file_count, 1);
assert_eq!(unityfs.serialized_file_count, 0);
assert_eq!(unityfs.text_asset_count, 0);
assert!(unityfs.text_assets.is_empty());
assert_eq!(unityfs.serialized_parse_error_count, 0);
assert_eq!(
report.imported[1].category,
ResourceImportCategory::TextAsset
@@ -645,6 +766,41 @@ mod tests {
);
}
#[tokio::test]
async fn import_summary_reports_text_assets_inside_assetbundle() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let manifest = manifest_with(vec![ResourceEntry {
path: "synthetic/scenario.bundle".to_string(),
hash: "synthetic-scenario-hash".to_string(),
size: 1,
resource_type: ResourceType::AssetBundle,
address: None,
dependencies: Vec::new(),
crc: None,
}]);
let report = service
.import_manifest_bundles(
&manifest,
&[BundleSource::new(
"scenario.bundle",
synthetic_text_asset_unityfs_bundle(),
)],
)
.await
.unwrap();
let unityfs = report.imported[0].unityfs.as_ref().unwrap();
assert_eq!(unityfs.file_count, 1);
assert_eq!(unityfs.serialized_file_count, 1);
assert_eq!(unityfs.text_asset_count, 1);
assert_eq!(unityfs.text_assets, vec!["Scenario".to_string()]);
assert_eq!(unityfs.serialized_parse_error_count, 0);
}
#[tokio::test]
async fn returns_error_when_bundle_data_is_missing() {
let temp_dir = TempDir::new().unwrap();
+12 -5
View File
@@ -16,6 +16,7 @@ pub mod import;
pub mod official_download;
pub mod official_game_main_config;
pub mod official_launcher;
pub mod official_parse;
pub mod official_pull;
pub mod official_sync;
pub mod official_update;
@@ -45,6 +46,12 @@ pub use official_launcher::{
OfficialLauncherBootstrapService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
};
pub use official_parse::{
read_parse_cache_at, write_parse_cache_at, OfficialParseCache, OfficialParseCacheEntry,
OfficialParseCacheService, OfficialParseConfig, OfficialParseReport,
OfficialParseSourceFingerprint, OfficialParseSourceKind, OfficialParseStatus,
OfficialParseSummary, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_PARSE_CACHE_VERSION,
};
pub use official_pull::{
build_official_pull_plan, build_official_pull_plan_for_platform_inventory,
build_official_pull_plan_for_platforms, build_official_pull_plan_from_platform_inventory,
@@ -58,11 +65,11 @@ pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot,
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
ResolvedBootstrap,
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
};
pub use path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
+950
View File
@@ -0,0 +1,950 @@
//! Official resource post-sync parse cache.
//!
//! The parser runs after official download verification has completed. It reads
//! the immutable published resource tree, writes a small derived cache next to
//! `official-download-manifest.json`, and leaves localized output generation to
//! later patch/export stages.
use crate::official_download::{read_download_manifest_at, OfficialDownloadManifestEntry};
use crate::path_security::{
ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic,
STATE_FILE_MODE,
};
use bat_assetbundle::{Parser, UnityFsParser};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// Parse-cache file name stored under a published official resource root.
pub const OFFICIAL_PARSE_CACHE_FILE: &str = "official-parse-cache.json";
/// Current parse-cache schema version.
pub const OFFICIAL_PARSE_CACHE_VERSION: u32 = 1;
/// Configuration for one official resource parse-cache refresh.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialParseConfig {
/// Published official resource root containing `official-download-manifest.json`.
pub resource_root: PathBuf,
/// `unzip` executable used to inspect zip archives without extracting them.
pub unzip_command: PathBuf,
}
impl OfficialParseConfig {
/// Creates parse-cache configuration for a published official resource root.
pub fn new(resource_root: impl Into<PathBuf>, unzip_command: impl Into<PathBuf>) -> Self {
Self {
resource_root: resource_root.into(),
unzip_command: unzip_command.into(),
}
}
/// Returns the parse-cache path for this resource root.
pub fn cache_path(&self) -> PathBuf {
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
}
}
/// Structured report returned by a parse-cache refresh.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseReport {
/// Official resource root that was inspected.
pub resource_root: PathBuf,
/// Parse-cache path written by the refresh.
pub cache_path: PathBuf,
/// Aggregate parse-cache summary.
pub summary: OfficialParseSummary,
}
/// Aggregate counters for a parse-cache refresh.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseSummary {
/// Number of entries in `official-download-manifest.json`.
pub manifest_entry_count: usize,
/// Number of entries stored in `official-parse-cache.json`.
pub cache_entry_count: usize,
/// Number of direct bundle files or zip inner files inspected as parser candidates.
pub candidate_file_count: usize,
/// Number of zip inner file entries inspected.
pub zip_entry_count: usize,
/// Number of cached entries reused because source URL/path/size/BLAKE3 did not change.
pub skipped_unchanged_count: usize,
/// Number of bundles parsed successfully.
pub parsed_bundle_count: usize,
/// Number of files intentionally skipped because they are not UnityFS bundles.
pub unsupported_count: usize,
/// Number of files or archives that failed parser/cache inspection.
pub failed_count: usize,
/// Total TextAsset objects found in parsed Unity serialized files.
pub text_asset_count: usize,
}
/// Persistent parse cache for one official resource root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseCache {
/// Cache schema version.
#[serde(default = "default_parse_cache_version")]
pub version: u32,
/// Cache generation time as Unix seconds.
pub generated_unix_seconds: u64,
/// Aggregate summary for this cache.
pub summary: OfficialParseSummary,
/// Cache entries keyed by source URL and optional archive entry.
#[serde(default)]
pub entries: BTreeMap<String, OfficialParseCacheEntry>,
}
/// One parsed or skipped official resource file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseCacheEntry {
/// Stable cache key.
pub key: String,
/// Official URL from the download manifest.
pub source_url: String,
/// Relative destination path under the official resource root.
pub destination: String,
/// Inner archive path when the source is a zip file.
pub archive_entry: Option<String>,
/// Source classification used by the parser.
pub source_kind: OfficialParseSourceKind,
/// Fingerprint derived from the download manifest.
pub fingerprint: OfficialParseSourceFingerprint,
/// Parse status for this cache entry.
pub status: OfficialParseStatus,
/// Whether this entry was reused from the previous parse cache in this run.
#[serde(default)]
pub reused_from_previous_cache: bool,
/// Unity editor version when a UnityFS bundle was parsed.
pub unity_version: Option<String>,
/// Number of files extracted from the UnityFS directory table.
pub file_count: usize,
/// Number of Unity serialized files parsed from extracted files.
pub serialized_file_count: usize,
/// Number of TextAsset objects found.
pub text_asset_count: usize,
/// TextAsset names found in stable order.
pub text_assets: Vec<String>,
/// Non-fatal serialized-file parse diagnostic count.
pub serialized_parse_error_count: usize,
/// Human-readable error or skip reason.
pub error: Option<String>,
}
/// Source kind for a parse-cache entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OfficialParseSourceKind {
/// A direct official resource file that should be a UnityFS bundle.
DirectBundle,
/// A file entry streamed from an official zip archive.
ZipEntry,
/// A manifest entry that is not expected to contain a UnityFS bundle.
Unsupported,
}
/// Stable fingerprint copied from the official download manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseSourceFingerprint {
/// Official URL from the download manifest.
pub source_url: String,
/// Relative destination path under the official resource root.
pub destination: String,
/// File byte count from the verified download manifest.
pub bytes: u64,
/// BLAKE3 digest from the verified download manifest.
pub blake3: String,
}
/// Parse status for one official resource cache entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OfficialParseStatus {
/// UnityFS parsing succeeded.
Parsed,
/// The source was inspected and is not currently parsed as UnityFS.
SkippedUnsupported,
/// The source was expected to be parseable but failed inspection.
Failed,
}
/// Service that refreshes official parse caches.
#[derive(Debug, Default, Clone, Copy)]
pub struct OfficialParseCacheService;
impl OfficialParseCacheService {
/// Creates a parse-cache service.
pub fn new() -> Self {
Self
}
/// Refreshes the parse cache for the configured official resource root.
pub fn run(&self, config: &OfficialParseConfig) -> Result<OfficialParseReport, String> {
let manifest = read_download_manifest_at(&config.resource_root)?.ok_or_else(|| {
format!(
"缺少官方下载 manifest,无法更新解析缓存:{}",
config.resource_root.display()
)
})?;
let previous_cache = read_parse_cache_at(&config.resource_root)?;
let mut summary = OfficialParseSummary {
manifest_entry_count: manifest.entries.len(),
..OfficialParseSummary::default()
};
let mut entries = BTreeMap::new();
for manifest_entry in manifest.entries.values() {
let produced = process_manifest_entry(config, manifest_entry, previous_cache.as_ref());
for entry in produced {
summary.record_entry(&entry);
entries.insert(entry.key.clone(), entry);
}
}
summary.cache_entry_count = entries.len();
let cache = OfficialParseCache {
version: OFFICIAL_PARSE_CACHE_VERSION,
generated_unix_seconds: unix_seconds_now(),
summary: summary.clone(),
entries,
};
write_parse_cache_at(&config.resource_root, &cache)?;
Ok(OfficialParseReport {
resource_root: config.resource_root.clone(),
cache_path: config.cache_path(),
summary,
})
}
}
impl OfficialParseSummary {
fn record_entry(&mut self, entry: &OfficialParseCacheEntry) {
match entry.source_kind {
OfficialParseSourceKind::DirectBundle => {
self.candidate_file_count += 1;
}
OfficialParseSourceKind::ZipEntry => {
self.candidate_file_count += 1;
self.zip_entry_count += 1;
}
OfficialParseSourceKind::Unsupported => {}
}
match entry.status {
OfficialParseStatus::Parsed => {
self.parsed_bundle_count += 1;
self.text_asset_count += entry.text_asset_count;
}
OfficialParseStatus::SkippedUnsupported => {
self.unsupported_count += 1;
}
OfficialParseStatus::Failed => {
self.failed_count += 1;
}
}
if entry.reused_from_previous_cache {
self.skipped_unchanged_count += 1;
}
}
}
/// Reads the parse cache under a published official resource root.
///
/// Missing, corrupt, or unsupported-version caches return `Ok(None)` so a new
/// cache can be regenerated without blocking official resource publication.
pub fn read_parse_cache_at(resource_root: &Path) -> Result<Option<OfficialParseCache>, String> {
let path = resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
let Some(bytes) = read_file_no_symlink(&path, "官方解析缓存")? else {
return Ok(None);
};
let Ok(cache) = serde_json::from_slice::<OfficialParseCache>(&bytes) else {
return Ok(None);
};
if cache.version != OFFICIAL_PARSE_CACHE_VERSION {
return Ok(None);
}
Ok(Some(cache))
}
/// Writes the parse cache under a published official resource root.
pub fn write_parse_cache_at(
resource_root: &Path,
cache: &OfficialParseCache,
) -> Result<(), String> {
let path = resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
ensure_path_within_root(resource_root, &path)?;
ensure_safe_file_target(resource_root, &path, "官方解析缓存")?;
let bytes = serde_json::to_vec_pretty(cache)
.map_err(|error| format!("序列化官方解析缓存失败:{error}"))?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方解析缓存")
}
fn process_manifest_entry(
config: &OfficialParseConfig,
manifest_entry: &OfficialDownloadManifestEntry,
previous_cache: Option<&OfficialParseCache>,
) -> Vec<OfficialParseCacheEntry> {
let fingerprint = fingerprint_for(manifest_entry);
if looks_like_zip_source(manifest_entry) {
return process_zip_entry(config, manifest_entry, previous_cache, fingerprint);
}
if looks_like_direct_bundle_source(manifest_entry) {
let key = direct_key(&manifest_entry.url);
if let Some(mut cached) = reusable_entry(previous_cache, &key, &fingerprint) {
cached.reused_from_previous_cache = true;
return vec![cached];
}
return vec![parse_direct_bundle(
config,
manifest_entry,
key,
fingerprint,
)];
}
let key = unsupported_key(&manifest_entry.url);
if let Some(mut cached) = reusable_entry(previous_cache, &key, &fingerprint) {
cached.reused_from_previous_cache = true;
return vec![cached];
}
vec![unsupported_entry(
manifest_entry,
None,
OfficialParseSourceKind::Unsupported,
fingerprint,
key,
"非 UnityFS 候选资源",
)]
}
fn process_zip_entry(
config: &OfficialParseConfig,
manifest_entry: &OfficialDownloadManifestEntry,
previous_cache: Option<&OfficialParseCache>,
fingerprint: OfficialParseSourceFingerprint,
) -> Vec<OfficialParseCacheEntry> {
let cached_entries = reusable_archive_entries(previous_cache, manifest_entry, &fingerprint);
if !cached_entries.is_empty() {
return cached_entries
.into_iter()
.map(|mut entry| {
entry.reused_from_previous_cache = true;
entry
})
.collect();
}
let archive_path = match resource_path_for(&config.resource_root, manifest_entry) {
Ok(path) => path,
Err(error) => {
return vec![failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::ZipEntry,
fingerprint,
zip_list_key(&manifest_entry.url),
error,
)]
}
};
let archive_entries = match list_zip_entries(&config.unzip_command, &archive_path) {
Ok(entries) => entries,
Err(error) => {
return vec![failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::ZipEntry,
fingerprint,
zip_list_key(&manifest_entry.url),
error,
)]
}
};
let mut produced = Vec::new();
for archive_entry in archive_entries {
let key = zip_key(&manifest_entry.url, &archive_entry);
let bytes = match read_zip_entry_bytes(&config.unzip_command, &archive_path, &archive_entry)
{
Ok(bytes) => bytes,
Err(error) => {
produced.push(failed_entry(
manifest_entry,
Some(archive_entry),
OfficialParseSourceKind::ZipEntry,
fingerprint.clone(),
key,
error,
));
continue;
}
};
produced.push(parse_zip_inner_file(
manifest_entry,
archive_entry,
fingerprint.clone(),
key,
&bytes,
));
}
if produced.is_empty() {
produced.push(unsupported_entry(
manifest_entry,
None,
OfficialParseSourceKind::Unsupported,
fingerprint,
zip_list_key(&manifest_entry.url),
"ZIP 内没有可检查文件条目",
));
}
produced
}
fn parse_direct_bundle(
config: &OfficialParseConfig,
manifest_entry: &OfficialDownloadManifestEntry,
key: String,
fingerprint: OfficialParseSourceFingerprint,
) -> OfficialParseCacheEntry {
let path = match resource_path_for(&config.resource_root, manifest_entry) {
Ok(path) => path,
Err(error) => {
return failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
error,
)
}
};
let bytes = match read_resource_file(&path) {
Ok(bytes) => bytes,
Err(error) => {
return failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
error,
)
}
};
if !UnityFsParser::has_unityfs_signature(&bytes) {
return unsupported_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
"文件不是 UnityFS bundle",
);
}
parsed_bundle_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
&bytes,
)
}
fn parse_zip_inner_file(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: String,
fingerprint: OfficialParseSourceFingerprint,
key: String,
bytes: &[u8],
) -> OfficialParseCacheEntry {
if !UnityFsParser::has_unityfs_signature(bytes) {
return unsupported_entry(
manifest_entry,
Some(archive_entry),
OfficialParseSourceKind::ZipEntry,
fingerprint,
key,
"ZIP 条目不是 UnityFS bundle",
);
}
parsed_bundle_entry(
manifest_entry,
Some(archive_entry),
OfficialParseSourceKind::ZipEntry,
fingerprint,
key,
bytes,
)
}
fn parsed_bundle_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
bytes: &[u8],
) -> OfficialParseCacheEntry {
let parser = UnityFsParser::new();
match parser.parse(bytes) {
Ok(parsed) => OfficialParseCacheEntry {
key,
source_url: manifest_entry.url.clone(),
destination: manifest_entry.destination.clone(),
archive_entry,
source_kind,
fingerprint,
status: OfficialParseStatus::Parsed,
reused_from_previous_cache: false,
unity_version: Some(parsed.unity_version),
file_count: parsed.files.len(),
serialized_file_count: parsed.serialized_files.len(),
text_asset_count: parsed.text_assets.len(),
text_assets: parsed
.text_assets
.iter()
.map(|asset| asset.name.clone())
.collect(),
serialized_parse_error_count: parsed.serialized_parse_errors.len(),
error: None,
},
Err(error) => failed_entry(
manifest_entry,
archive_entry,
source_kind,
fingerprint,
key,
error.to_string(),
),
}
}
fn failed_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
error: impl Into<String>,
) -> OfficialParseCacheEntry {
status_entry(
manifest_entry,
archive_entry,
source_kind,
fingerprint,
key,
OfficialParseStatus::Failed,
error.into(),
)
}
fn unsupported_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
reason: impl Into<String>,
) -> OfficialParseCacheEntry {
status_entry(
manifest_entry,
archive_entry,
source_kind,
fingerprint,
key,
OfficialParseStatus::SkippedUnsupported,
reason.into(),
)
}
fn status_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
status: OfficialParseStatus,
reason: String,
) -> OfficialParseCacheEntry {
OfficialParseCacheEntry {
key,
source_url: manifest_entry.url.clone(),
destination: manifest_entry.destination.clone(),
archive_entry,
source_kind,
fingerprint,
status,
reused_from_previous_cache: false,
unity_version: None,
file_count: 0,
serialized_file_count: 0,
text_asset_count: 0,
text_assets: Vec::new(),
serialized_parse_error_count: 0,
error: Some(reason),
}
}
fn read_resource_file(path: &Path) -> Result<Vec<u8>, String> {
read_file_no_symlink(path, "官方解析输入")?
.ok_or_else(|| format!("官方解析输入不存在:{}", path.display()))
}
fn resource_path_for(
resource_root: &Path,
manifest_entry: &OfficialDownloadManifestEntry,
) -> Result<PathBuf, String> {
let path = resource_root.join(Path::new(&manifest_entry.destination));
ensure_path_within_root(resource_root, &path)?;
ensure_safe_file_target(resource_root, &path, "官方解析输入")?;
Ok(path)
}
fn list_zip_entries(unzip_command: &Path, archive_path: &Path) -> Result<Vec<String>, String> {
let output = Command::new(unzip_command)
.arg("-Z1")
.arg(archive_path)
.output()
.map_err(|error| {
format!(
"启动 unzip 列出 ZIP 条目失败 {}{error}",
archive_path.display()
)
})?;
if !output.status.success() {
return Err(format!(
"列出 ZIP 条目失败 {}{}",
archive_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim_end_matches('\r').trim().to_string())
.filter(|line| !line.is_empty() && !line.ends_with('/'))
.collect())
}
fn read_zip_entry_bytes(
unzip_command: &Path,
archive_path: &Path,
archive_entry: &str,
) -> Result<Vec<u8>, String> {
let output = Command::new(unzip_command)
.arg("-p")
.arg(archive_path)
.arg(archive_entry)
.output()
.map_err(|error| {
format!(
"启动 unzip 读取 ZIP 条目失败 {}!{}{error}",
archive_path.display(),
archive_entry
)
})?;
if !output.status.success() {
return Err(format!(
"读取 ZIP 条目失败 {}!{}{}",
archive_path.display(),
archive_entry,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(output.stdout)
}
fn reusable_entry(
previous_cache: Option<&OfficialParseCache>,
key: &str,
fingerprint: &OfficialParseSourceFingerprint,
) -> Option<OfficialParseCacheEntry> {
previous_cache
.and_then(|cache| cache.entries.get(key))
.filter(|entry| &entry.fingerprint == fingerprint)
.cloned()
}
fn reusable_archive_entries(
previous_cache: Option<&OfficialParseCache>,
manifest_entry: &OfficialDownloadManifestEntry,
fingerprint: &OfficialParseSourceFingerprint,
) -> Vec<OfficialParseCacheEntry> {
previous_cache
.into_iter()
.flat_map(|cache| cache.entries.values())
.filter(|entry| {
entry.source_url == manifest_entry.url
&& entry.destination == manifest_entry.destination
&& &entry.fingerprint == fingerprint
})
.cloned()
.collect()
}
fn fingerprint_for(
manifest_entry: &OfficialDownloadManifestEntry,
) -> OfficialParseSourceFingerprint {
OfficialParseSourceFingerprint {
source_url: manifest_entry.url.clone(),
destination: manifest_entry.destination.clone(),
bytes: manifest_entry.bytes,
blake3: manifest_entry.blake3.clone(),
}
}
fn looks_like_zip_source(manifest_entry: &OfficialDownloadManifestEntry) -> bool {
has_case_insensitive_suffix(&manifest_entry.url, ".zip")
|| has_case_insensitive_suffix(&manifest_entry.destination, ".zip")
}
fn looks_like_direct_bundle_source(manifest_entry: &OfficialDownloadManifestEntry) -> bool {
[".bundle", ".unity3d"].iter().any(|suffix| {
has_case_insensitive_suffix(&manifest_entry.url, suffix)
|| has_case_insensitive_suffix(&manifest_entry.destination, suffix)
})
}
fn has_case_insensitive_suffix(value: &str, suffix: &str) -> bool {
value
.rsplit(['/', '\\'])
.next()
.is_some_and(|name| name.to_ascii_lowercase().ends_with(suffix))
}
fn direct_key(url: &str) -> String {
format!("direct:{url}")
}
fn zip_key(url: &str, archive_entry: &str) -> String {
format!("zip:{url}!{archive_entry}")
}
fn zip_list_key(url: &str) -> String {
format!("zip-list:{url}")
}
fn unsupported_key(url: &str) -> String {
format!("unsupported:{url}")
}
fn default_parse_cache_version() -> u32 {
OFFICIAL_PARSE_CACHE_VERSION
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_unityfs_bundle(directory_path: &str, payload: &[u8]) -> Vec<u8> {
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0xAB; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, payload.len() as u32);
push_u32(&mut blocks_info, payload.len() as u32);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, payload.len() as u64);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, directory_path);
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, 16);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(payload);
let total_size = data.len() as u64;
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn write_download_manifest(
root: &Path,
entries: &[(&str, &str, &[u8])],
) -> Vec<OfficialDownloadManifestEntry> {
let mut manifest_entries = BTreeMap::new();
let mut written = Vec::new();
for (url, destination, bytes) in entries {
let path = root.join(destination);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, bytes).unwrap();
let entry = OfficialDownloadManifestEntry {
url: (*url).to_string(),
destination: (*destination).to_string(),
bytes: bytes.len() as u64,
blake3: blake3::hash(bytes).to_hex().to_string(),
};
manifest_entries.insert((*url).to_string(), entry.clone());
written.push(entry);
}
let manifest = serde_json::json!({
"version": 1,
"entries": manifest_entries,
});
fs::write(
root.join("official-download-manifest.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
written
}
#[test]
fn parses_direct_bundle_and_reuses_unchanged_cache() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let bundle = synthetic_unityfs_bundle("CAB-fixture", b"data");
write_download_manifest(
root,
&[(
"https://prod-clientpatch.bluearchiveyostar.com/r93/Bundle/test.bundle",
"Bundle/test.bundle",
&bundle,
)],
);
let config = OfficialParseConfig::new(root, "unzip");
let first = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(first.summary.manifest_entry_count, 1);
assert_eq!(first.summary.parsed_bundle_count, 1);
assert_eq!(first.summary.skipped_unchanged_count, 0);
assert!(first.cache_path.exists());
let second = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(second.summary.parsed_bundle_count, 1);
assert_eq!(second.summary.skipped_unchanged_count, 1);
let cache = read_parse_cache_at(root).unwrap().unwrap();
let entry = cache.entries.values().next().unwrap();
assert_eq!(entry.status, OfficialParseStatus::Parsed);
assert_eq!(entry.file_count, 1);
}
#[test]
fn records_non_candidate_resources_as_unsupported() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
write_download_manifest(
root,
&[(
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/TableCatalog.bytes",
"TableBundles/TableCatalog.bytes",
b"catalog",
)],
);
let config = OfficialParseConfig::new(root, "unzip");
let report = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(report.summary.parsed_bundle_count, 0);
assert_eq!(report.summary.unsupported_count, 1);
assert_eq!(report.summary.failed_count, 0);
}
#[cfg(unix)]
#[test]
fn parses_zip_entries_without_extracting_archive_tree() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let bundle = synthetic_unityfs_bundle("CAB-zipped", b"zipdata");
let bundle_path = temp.path().join("inner.bundle");
fs::write(&bundle_path, &bundle).unwrap();
let archive_bytes = b"zip-placeholder";
write_download_manifest(
root,
&[(
"https://prod-clientpatch.bluearchiveyostar.com/r93/Windows_PatchPack/FullPatch_000.zip",
"Windows_PatchPack/FullPatch_000.zip",
archive_bytes,
)],
);
let unzip_script = temp.path().join("fake-unzip");
fs::write(
&unzip_script,
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
if [[ "${{1:-}}" == "-Z1" ]]; then
printf '%s\n' 'assets/scenario.bundle' 'assets/readme.txt'
exit 0
fi
if [[ "${{1:-}}" == "-p" && "${{3:-}}" == "assets/scenario.bundle" ]]; then
cat '{}'
exit 0
fi
if [[ "${{1:-}}" == "-p" ]]; then
printf 'plain text'
exit 0
fi
exit 2
"#,
bundle_path.display()
),
)
.unwrap();
let mut permissions = fs::metadata(&unzip_script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&unzip_script, permissions).unwrap();
let config = OfficialParseConfig::new(root, unzip_script);
let report = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(report.summary.manifest_entry_count, 1);
assert_eq!(report.summary.zip_entry_count, 2);
assert_eq!(report.summary.parsed_bundle_count, 1);
assert_eq!(report.summary.unsupported_count, 1);
assert_eq!(report.summary.failed_count, 0);
assert!(!root.join("assets").exists());
}
}
+151 -1
View File
@@ -7,7 +7,7 @@
use crate::curl_transfer::{resolve_curl_proxy, CurlProxyConfig};
use crate::path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
};
use crate::{
@@ -18,6 +18,7 @@ use crate::{
OfficialResourcePullService, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl,
YostarJpLauncherRemoteManifest,
};
use crate::{OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
@@ -78,6 +79,8 @@ pub struct OfficialUpdateConfig {
pub platforms: Option<Vec<PatchPlatform>>,
/// Output root for resources and state files.
pub output_root: PathBuf,
/// Output root reserved for localized resources generated from official data.
pub localized_output_root: PathBuf,
/// Optional explicit sync snapshot path.
pub snapshot_path: Option<PathBuf>,
/// Curl command used by the current infrastructure downloader.
@@ -108,6 +111,7 @@ impl Default for OfficialUpdateConfig {
auto_discover: false,
platforms: None,
output_root: PathBuf::from("./bat-resources"),
localized_output_root: PathBuf::from("./bat-localized"),
snapshot_path: None,
curl_command: PathBuf::from("curl"),
curl_proxy: CurlProxyConfig::default(),
@@ -168,6 +172,26 @@ impl OfficialUpdateStatus {
}
}
/// Publication state for localized resources associated with an official release.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocalizedReleaseStatus {
/// Official resources are published, but localized resources are not.
NotLocalized,
/// Official resources and localized resources are both published.
Localized,
}
impl LocalizedReleaseStatus {
/// Returns a stable string label for CLI and JSON callers.
pub fn as_str(self) -> &'static str {
match self {
Self::NotLocalized => "not_localized",
Self::Localized => "localized",
}
}
}
/// Snapshot of the official JP update state observed at a point in time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialUpdateSnapshot {
@@ -477,6 +501,14 @@ pub struct OfficialUpdateReport {
pub platforms: Vec<PatchPlatform>,
/// Managed publish root containing `current`, `versions`, and staging.
pub output_root: PathBuf,
/// Separate root reserved for localized resources.
pub localized_output_root: PathBuf,
/// Localized publication state for this official resource version.
pub localized_release_status: LocalizedReleaseStatus,
/// Future localized `current` pointer path under `localized_output_root`.
pub localized_current_path: PathBuf,
/// Versioned localized release directory when localized resources are published.
pub localized_published_version_path: Option<PathBuf>,
/// Active resource root used for local audit before this run.
pub active_resource_root: PathBuf,
/// Atomic `current` pointer path.
@@ -543,6 +575,10 @@ pub struct OfficialUpdateReport {
pub verification_summary: OfficialVerificationSummary,
/// Download manifest path.
pub download_manifest: PathBuf,
/// Parse-cache path written or refreshed after successful verification.
pub parse_cache_path: Option<PathBuf>,
/// Post-sync parse-cache summary.
pub parse_summary: Option<OfficialParseSummary>,
/// Snapshot path written after success.
pub snapshot_written: Option<PathBuf>,
}
@@ -1175,6 +1211,10 @@ impl OfficialUpdateService {
addressables_root: current_snapshot.addressables_root.clone(),
platforms: platforms.to_vec(),
output_root: config.output_root.clone(),
localized_output_root: config.localized_output_root.clone(),
localized_release_status: LocalizedReleaseStatus::NotLocalized,
localized_current_path: config.localized_output_root.join(OFFICIAL_CURRENT_LINK),
localized_published_version_path: None,
active_resource_root: active_resource_root.clone(),
current_path: publish_layout.current_path.clone(),
version_state_path: version_state_path.clone(),
@@ -1214,6 +1254,8 @@ impl OfficialUpdateService {
local_zip_structure_verified_count,
),
download_manifest: fetcher.download_manifest_path(),
parse_cache_path: None,
parse_summary: None,
snapshot_written: None,
};
@@ -1242,6 +1284,13 @@ impl OfficialUpdateService {
&active_resource_root,
&snapshot_path,
)?;
run_post_sync_parse_cache(
config,
&active_resource_root,
&mut report,
&mut progress,
&mut should_cancel,
)?;
}
progress(OfficialUpdateProgress::new(
"finish",
@@ -1454,6 +1503,13 @@ impl OfficialUpdateService {
format!("资源已发布,但写入版本状态失败(下轮可重试):{error}"),
));
}
run_post_sync_parse_cache(
config,
&published_version_path,
&mut report,
&mut progress,
&mut should_cancel,
)?;
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
match read_version_state(&version_state_path) {
@@ -1487,6 +1543,46 @@ impl OfficialUpdateService {
}
}
fn run_post_sync_parse_cache(
config: &OfficialUpdateConfig,
resource_root: &Path,
report: &mut OfficialUpdateReport,
progress: &mut dyn FnMut(OfficialUpdateProgress),
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<()> {
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"parse",
format!("更新官方资源解析缓存 {}", resource_root.display()),
));
let parse_config = OfficialParseConfig::new(resource_root, &config.unzip_command);
match OfficialParseCacheService::new().run(&parse_config) {
Ok(parse_report) => {
progress(OfficialUpdateProgress::new(
"parse",
format!(
"解析缓存完成:条目={} 已解析={} 复用={} 不支持={} 失败={} TextAsset={}",
parse_report.summary.cache_entry_count,
parse_report.summary.parsed_bundle_count,
parse_report.summary.skipped_unchanged_count,
parse_report.summary.unsupported_count,
parse_report.summary.failed_count,
parse_report.summary.text_asset_count
),
));
report.parse_cache_path = Some(parse_report.cache_path);
report.parse_summary = Some(parse_report.summary);
}
Err(error) => {
progress(OfficialUpdateProgress::new(
"parse",
format!("解析缓存更新失败(不影响已校验官方资源):{error}"),
));
}
}
Ok(())
}
fn build_pull_plan(
server_info: &YostarJpServerInfo,
connection_group: &str,
@@ -1751,7 +1847,10 @@ pub fn diff_extended_snapshot(
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
validate_output_root(&config.output_root)?;
validate_output_root(&config.localized_output_root)?;
validate_separate_output_roots(&config.output_root, &config.localized_output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")?;
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
ensure_path_within_root(&config.output_root, snapshot_path)?;
ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?;
@@ -1770,6 +1869,28 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
Ok(())
}
fn validate_separate_output_roots(
output_root: &Path,
localized_output_root: &Path,
) -> Result<(), String> {
let official = lexical_absolute(output_root)?;
let localized = lexical_absolute(localized_output_root)?;
if official == localized {
return Err(format!(
"官方资源目录和汉化输出目录不能相同:{}",
official.display()
));
}
if localized.starts_with(&official) || official.starts_with(&localized) {
return Err(format!(
"官方资源目录和汉化输出目录不能互相嵌套:官方={} 汉化={}",
official.display(),
localized.display()
));
}
Ok(())
}
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
config
.snapshot_path
@@ -3412,6 +3533,35 @@ mod tests {
assert!(error.to_string().contains("危险路径"));
}
#[test]
fn update_rejects_same_official_and_localized_output_root() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("resources");
let config = OfficialUpdateConfig {
output_root: root.clone(),
localized_output_root: root,
dry_run: true,
..OfficialUpdateConfig::default()
};
let error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(error.to_string().contains("不能相同"));
}
#[test]
fn update_rejects_nested_official_and_localized_output_roots() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().join("resources"),
localized_output_root: temp.path().join("resources/localized"),
dry_run: true,
..OfficialUpdateConfig::default()
};
let error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(error.to_string().contains("不能互相嵌套"));
}
#[test]
fn update_rejects_snapshot_path_escape() {
let temp = tempfile::TempDir::new().unwrap();