mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(assetbundle): 完善官方资源解析与双目录发布
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user