From 2079c6a307485e479e90944c9ba08b4bc99e2525 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 31 Jul 2026 00:38:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E6=8E=A5=E5=85=A5=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=BC=93=E5=AD=98=E4=B8=8E=E6=B1=89=E5=8C=96=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=89=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐官方 release 解析缓存、TextUnit 明细索引、资源变更集、Crowdin handoff 预留、ResourceRepository 导入元数据和 localized release patch 前置链路。 同时开放文件级 patch.apply 与 UnityFS TextAsset/string/semantic field patch CLI/RPC 入口,并保留官方原版资源与汉化产物双目录发布状态。 验证:cargo test -p bat-assetbundle --locked;cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings;cargo test -p bat-infrastructure --locked。 --- Cargo.lock | 2 + adapters/src/unity.rs | 4 +- adapters/src/unity/serialized_file.rs | 4 +- core/src/domain/mod.rs | 4 +- core/src/domain/resource.rs | 55 + crates/bat-assetbundle/Cargo.toml | 1 + crates/bat-assetbundle/src/lib.rs | 16 +- crates/bat-assetbundle/src/patch.rs | 1979 ++++++ crates/bat-assetbundle/src/serialized.rs | 5981 +++++++++++++++++ crates/bat-assetbundle/src/text.rs | 949 +++ infrastructure/Cargo.toml | 1 + infrastructure/src/bin/bat_official_sync.rs | 2674 +++++++- infrastructure/src/import.rs | 29 +- infrastructure/src/lib.rs | 50 +- infrastructure/src/localized_patch.rs | 857 +++ infrastructure/src/official_changes.rs | 653 ++ infrastructure/src/official_download.rs | 12 +- .../src/official_game_main_config.rs | 111 +- infrastructure/src/official_parse.rs | 648 +- infrastructure/src/official_repository.rs | 543 ++ infrastructure/src/official_textunit_queue.rs | 834 +++ infrastructure/src/official_update.rs | 1436 +++- infrastructure/src/patch_ops.rs | 424 ++ infrastructure/src/resources.rs | 70 +- .../official_game_main_config_bootstrap.rs | 175 +- 25 files changed, 17287 insertions(+), 225 deletions(-) create mode 100644 crates/bat-assetbundle/src/patch.rs create mode 100644 crates/bat-assetbundle/src/text.rs create mode 100644 infrastructure/src/localized_patch.rs create mode 100644 infrastructure/src/official_changes.rs create mode 100644 infrastructure/src/official_repository.rs create mode 100644 infrastructure/src/official_textunit_queue.rs create mode 100644 infrastructure/src/patch_ops.rs diff --git a/Cargo.lock b/Cargo.lock index 65d5cbb..dcfacef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,7 @@ dependencies = [ "hex", "lz4", "lzma-rs", + "md-5", "serde", "serde_json", "thiserror", @@ -145,6 +146,7 @@ dependencies = [ "bat-assetbundle", "bat-cas-engine", "bat-core", + "bat-patch", "blake3", "hex", "libc", diff --git a/adapters/src/unity.rs b/adapters/src/unity.rs index 3cbe89b..f0da65a 100644 --- a/adapters/src/unity.rs +++ b/adapters/src/unity.rs @@ -13,7 +13,7 @@ pub use adapter::{ }; pub use registry::UnityAdapterRegistry; pub use serialized_file::{ - UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType, - UnityTypeTreeNode, + UnitySerializedField, UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, + UnitySerializedType, UnitySerializedValue, UnityTypeTreeNode, }; pub use unity_2021_3::Unity2021_3Adapter; diff --git a/adapters/src/unity/serialized_file.rs b/adapters/src/unity/serialized_file.rs index 9db2f3d..04bee71 100644 --- a/adapters/src/unity/serialized_file.rs +++ b/adapters/src/unity/serialized_file.rs @@ -4,6 +4,6 @@ //! existing call sites can continue to import through `bat_adapters::unity`. pub use bat_assetbundle::{ - UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType, - UnityTypeTreeNode, + UnitySerializedField, UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, + UnitySerializedType, UnitySerializedValue, UnityTypeTreeNode, }; diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index aacc4eb..41af38a 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -7,7 +7,9 @@ pub mod translation; pub use game_client::{ClientStatus, GameClient, GameRegion}; pub use game_version::{GameVersion, UnityVersion}; -pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType}; +pub use resource::{ + crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType, +}; pub use translation::{ ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText, TranslationStatus, diff --git a/core/src/domain/resource.rs b/core/src/domain/resource.rs index 1cb7cad..f2f1d79 100644 --- a/core/src/domain/resource.rs +++ b/core/src/domain/resource.rs @@ -43,6 +43,58 @@ pub struct ResourceEntry { pub crc: Option, } +/// 资源解析与发布侧元数据。 +/// +/// 该结构默认全空,保证旧索引和只保存基础 manifest 信息的资源仍可反序列化。 +/// 官方资源导入会按 release manifest 和 parse cache 填充这些字段,供 +/// `resource.index` 等只读接口暴露版本、平台、bundle、TextAsset 和 TextUnit 摘要。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ResourceMetadata { + /// 资源所属的官方 release ID。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub official_release_id: Option, + /// 从官方相对路径推断的平台标签,例如 `windows` 或 `android`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// 资源本身或所在 bundle 的官方相对路径。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundle_path: Option, + /// ZIP 内被解析到的 bundle entry;直接 bundle 为空。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub archive_entries: Vec, + /// parse cache 中出现过的解析状态标签。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parse_statuses: Vec, + /// 解析到的 Unity 版本集合。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unity_versions: Vec, + /// UnityFS directory file 总数。 + #[serde(default, skip_serializing_if = "is_zero")] + pub unityfs_file_count: u64, + /// Unity serialized file 总数。 + #[serde(default, skip_serializing_if = "is_zero")] + pub serialized_file_count: u64, + /// TextAsset 对象总数。 + #[serde(default, skip_serializing_if = "is_zero")] + pub text_asset_count: u64, + /// TextAsset 名称集合。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub text_assets: Vec, + /// TextUnit 总数。 + #[serde(default, skip_serializing_if = "is_zero")] + pub text_unit_count: u64, + /// TextUnit 格式标签集合,例如 `json`、`csv`、`tsv`、`plain`。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub text_unit_formats: Vec, + /// TextUnit 提取阶段的非致命诊断数量。 + #[serde(default, skip_serializing_if = "is_zero")] + pub text_unit_error_count: u64, +} + +fn is_zero(value: &u64) -> bool { + *value == 0 +} + /// 已下载字节与 catalog 声明的可校验字段不一致。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum IntegrityMismatch { @@ -133,6 +185,9 @@ pub struct Resource { pub local_path: PathBuf, /// 资源条目 pub entry: ResourceEntry, + /// 解析、发布和索引侧扩展元数据。 + #[serde(default)] + pub metadata: ResourceMetadata, } #[cfg(test)] diff --git a/crates/bat-assetbundle/Cargo.toml b/crates/bat-assetbundle/Cargo.toml index 4b91b89..b537f33 100644 --- a/crates/bat-assetbundle/Cargo.toml +++ b/crates/bat-assetbundle/Cargo.toml @@ -13,6 +13,7 @@ serde_json.workspace = true tracing.workspace = true lz4 = "1.28" lzma-rs = "0.3" +md-5 = "0.10" [dev-dependencies] hex = "0.4" diff --git a/crates/bat-assetbundle/src/lib.rs b/crates/bat-assetbundle/src/lib.rs index e38d29f..a69e28a 100644 --- a/crates/bat-assetbundle/src/lib.rs +++ b/crates/bat-assetbundle/src/lib.rs @@ -9,14 +9,26 @@ pub mod error; pub mod parser; +pub mod patch; pub mod serialized; +pub mod text; pub mod types; pub use error::{AssetBundleError, Result}; pub use parser::{compression_from_flags, Parser, UnityFsParser}; +pub use patch::{ + patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, + StringFieldPatch, TextAssetPatch, +}; pub use serialized::{ - UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType, - UnityTypeTreeNode, + UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField, + UnitySerializedFieldReplacement, UnitySerializedFile, UnitySerializedObject, + UnitySerializedReplacementValue, UnitySerializedTextAsset, UnitySerializedType, + UnitySerializedValue, UnityTypeTreeNode, +}; +pub use text::{ + text_units_to_jsonl, TextUnit, TextUnitExtractionError, TextUnitExtractionReport, + TextUnitExtractor, }; pub use types::{ AssetType, ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle, diff --git a/crates/bat-assetbundle/src/patch.rs b/crates/bat-assetbundle/src/patch.rs new file mode 100644 index 0000000..5bd6ea4 --- /dev/null +++ b/crates/bat-assetbundle/src/patch.rs @@ -0,0 +1,1979 @@ +//! Minimal UnityFS TextAsset patching. + +use crate::error::{AssetBundleError, Result}; +use crate::parser::UnityFsParser; +use crate::serialized::{ + UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue, +}; +use crate::types::UnityFsBundle; +use md5::{Digest, Md5}; + +/// One TextAsset replacement inside a serialized UnityFS directory file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextAssetPatch { + /// UnityFS directory path of the serialized file. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Optional expected TextAsset name. + pub expected_name: Option, + /// Replacement raw bytes. + pub replacement: Vec, +} + +impl TextAssetPatch { + /// Creates a TextAsset replacement specification. + pub fn new( + serialized_file_path: impl Into, + path_id: i64, + replacement: impl Into>, + ) -> Self { + Self { + serialized_file_path: serialized_file_path.into(), + path_id, + expected_name: None, + replacement: replacement.into(), + } + } +} + +/// One TypeTree string-field replacement inside a serialized UnityFS directory file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StringFieldPatch { + /// UnityFS directory path of the serialized file. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Stable TypeTree field path. + pub field_path: String, + /// Optional expected source string. + pub expected_value: Option, + /// Replacement string. + pub replacement: String, +} + +impl StringFieldPatch { + /// Creates a string-field replacement specification. + pub fn new( + serialized_file_path: impl Into, + path_id: i64, + field_path: impl Into, + replacement: impl Into, + ) -> Self { + Self { + serialized_file_path: serialized_file_path.into(), + path_id, + field_path: field_path.into(), + expected_value: None, + replacement: replacement.into(), + } + } +} + +/// One semantic TypeTree field replacement inside a serialized UnityFS directory file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldPatch { + /// UnityFS directory path of the serialized file. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Stable TypeTree field path. + pub field_path: String, + /// Optional expected source value. + pub expected_value: Option, + /// Replacement value encoded according to the current TypeTree field type. + pub replacement: UnitySerializedReplacementValue, +} + +impl FieldPatch { + /// Creates a semantic field replacement specification. + pub fn new( + serialized_file_path: impl Into, + path_id: i64, + field_path: impl Into, + replacement: UnitySerializedReplacementValue, + ) -> Self { + Self { + serialized_file_path: serialized_file_path.into(), + path_id, + field_path: field_path.into(), + expected_value: None, + replacement, + } + } +} + +/// Patches one TextAsset and rebuilds the UnityFS container. +/// +/// The rebuilt bundle uses a single uncompressed data block. This keeps the +/// patch path deterministic and avoids relying on a compressor-specific +/// implementation while preserving all directory file paths and metadata. +pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result> { + let parser = UnityFsParser::new(); + let mut bundle = parser.parse_bytes(data)?; + let serialized = bundle + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str())) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized file {} not found in UnityFS bundle", + patch.serialized_file_path + )) + })?; + let rewritten_serialized = serialized.replace_text_asset( + patch.path_id, + patch.expected_name.as_deref(), + &patch.replacement, + )?; + + let file = bundle + .files + .iter_mut() + .find(|file| file.path == patch.serialized_file_path) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "UnityFS directory file {} not found", + patch.serialized_file_path + )) + })?; + file.data = rewritten_serialized; + file.size = file.data.len() as u64; + + let rebuilt = rebuild_unityfs(&bundle)?; + let verified = parser.parse_bytes(&rebuilt)?; + let asset = verified + .text_assets + .iter() + .find(|asset| asset.path_id == patch.path_id) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "patched TextAsset path_id {} was not found after rebuild", + patch.path_id + )) + })?; + if asset.bytes != patch.replacement { + return Err(AssetBundleError::Parse(format!( + "patched TextAsset path_id {} failed post-build verification", + patch.path_id + ))); + } + Ok(rebuilt) +} + +/// Patches one TypeTree string field and rebuilds the UnityFS container. +pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Result> { + let parser = UnityFsParser::new(); + let mut bundle = parser.parse_bytes(data)?; + let serialized = bundle + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str())) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized file {} not found in UnityFS bundle", + patch.serialized_file_path + )) + })?; + let rewritten_serialized = serialized.replace_string_field( + patch.path_id, + &patch.field_path, + patch.expected_value.as_deref(), + &patch.replacement, + )?; + + let file = bundle + .files + .iter_mut() + .find(|file| file.path == patch.serialized_file_path) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "UnityFS directory file {} not found", + patch.serialized_file_path + )) + })?; + file.data = rewritten_serialized; + file.size = file.data.len() as u64; + + let rebuilt = rebuild_unityfs(&bundle)?; + let verified = parser.parse_bytes(&rebuilt)?; + let serialized = verified + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str())) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "patched serialized file {} was not found after rebuild", + patch.serialized_file_path + )) + })?; + let fields = serialized.fields_for_object(patch.path_id)?; + let value = find_string_field_value(&fields, &patch.field_path).ok_or_else(|| { + AssetBundleError::Parse(format!( + "patched field {} was not found after rebuild", + patch.field_path + )) + })?; + if value != patch.replacement { + return Err(AssetBundleError::Parse(format!( + "patched field {} failed post-build verification", + patch.field_path + ))); + } + Ok(rebuilt) +} + +/// Patches one semantic TypeTree field and rebuilds the UnityFS container. +pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result> { + let parser = UnityFsParser::new(); + let mut bundle = parser.parse_bytes(data)?; + let serialized = bundle + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str())) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized file {} not found in UnityFS bundle", + patch.serialized_file_path + )) + })?; + let rewritten_serialized = serialized.replace_field_value( + patch.path_id, + &patch.field_path, + patch.expected_value.as_ref(), + &patch.replacement, + )?; + + let file = bundle + .files + .iter_mut() + .find(|file| file.path == patch.serialized_file_path) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "UnityFS directory file {} not found", + patch.serialized_file_path + )) + })?; + file.data = rewritten_serialized; + file.size = file.data.len() as u64; + + let rebuilt = rebuild_unityfs(&bundle)?; + let verified = parser.parse_bytes(&rebuilt)?; + let serialized = verified + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str())) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "patched serialized file {} was not found after rebuild", + patch.serialized_file_path + )) + })?; + let fields = serialized.fields_for_object(patch.path_id)?; + let value = find_field_value(&fields, &patch.field_path).ok_or_else(|| { + AssetBundleError::Parse(format!( + "patched field {} was not found after rebuild", + patch.field_path + )) + })?; + if !patch.replacement.matches_serialized_value(value) { + return Err(AssetBundleError::Parse(format!( + "patched field {} failed post-build verification", + patch.field_path + ))); + } + Ok(rebuilt) +} + +fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result> { + if bundle.files.len() != bundle.directories.len() { + return Err(AssetBundleError::Parse(format!( + "UnityFS file/directory count mismatch: files={}, directories={}", + bundle.files.len(), + bundle.directories.len() + ))); + } + + let mut uncompressed_data = Vec::new(); + let mut directory_offsets = Vec::with_capacity(bundle.files.len()); + for file in &bundle.files { + let offset = u64::try_from(uncompressed_data.len()) + .map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?; + directory_offsets.push(offset); + uncompressed_data.extend_from_slice(&file.data); + } + + let data_size = u32::try_from(uncompressed_data.len()).map_err(|_| { + AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string()) + })?; + let mut blocks_info_body = Vec::new(); + push_i32_be(&mut blocks_info_body, 1); + push_u32_be(&mut blocks_info_body, data_size); + push_u32_be(&mut blocks_info_body, data_size); + push_u16_be(&mut blocks_info_body, 0); + push_i32_be( + &mut blocks_info_body, + i32::try_from(bundle.files.len()).map_err(|_| { + AssetBundleError::Parse("UnityFS directory count exceeds i32".to_string()) + })?, + ); + for (file, offset) in bundle.files.iter().zip(directory_offsets) { + push_u64_be(&mut blocks_info_body, offset); + push_u64_be( + &mut blocks_info_body, + u64::try_from(file.data.len()) + .map_err(|_| AssetBundleError::Parse("UnityFS file size overflow".to_string()))?, + ); + push_u32_be(&mut blocks_info_body, file.flags); + push_c_string(&mut blocks_info_body, &file.path); + } + let digest = Md5::digest(&blocks_info_body); + let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len()); + blocks_info.extend_from_slice(&digest); + blocks_info.extend_from_slice(&blocks_info_body); + + let mut output = Vec::new(); + push_c_string(&mut output, "UnityFS"); + push_u32_be(&mut output, bundle.header.format_version); + push_c_string(&mut output, &bundle.header.target_version); + push_c_string(&mut output, &bundle.header.unity_version); + let total_size_offset = output.len(); + push_u64_be(&mut output, 0); + push_u32_be( + &mut output, + u32::try_from(blocks_info.len()).map_err(|_| { + AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string()) + })?, + ); + push_u32_be( + &mut output, + u32::try_from(blocks_info.len()).map_err(|_| { + AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string()) + })?, + ); + push_u32_be(&mut output, 0); + if bundle.header.format_version >= 7 { + align_vec(&mut output, 16); + } + output.extend_from_slice(&blocks_info); + output.extend_from_slice(&uncompressed_data); + let total_size = u64::try_from(output.len()) + .map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?; + output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + Ok(output) +} + +fn find_field_value<'a>( + fields: &'a [UnitySerializedField], + field_path: &str, +) -> Option<&'a UnitySerializedValue> { + for field in fields { + if field.path == field_path { + return Some(&field.value); + } + match &field.value { + UnitySerializedValue::Object(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } + | UnitySerializedValue::Array(children) + | UnitySerializedValue::Map(children) => { + if let Some(found) = find_field_value(children, field_path) { + return Some(found); + } + } + _ => {} + } + } + None +} + +fn find_string_field_value<'a>( + fields: &'a [UnitySerializedField], + field_path: &str, +) -> Option<&'a str> { + for field in fields { + if field.path == field_path { + if let UnitySerializedValue::String(value) = &field.value { + return Some(value); + } + } + match &field.value { + UnitySerializedValue::Object(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } + | UnitySerializedValue::Array(children) + | UnitySerializedValue::Map(children) => { + if let Some(found) = find_string_field_value(children, field_path) { + return Some(found); + } + } + _ => {} + } + } + None +} + +fn push_c_string(output: &mut Vec, value: &str) { + output.extend_from_slice(value.as_bytes()); + output.push(0); +} + +fn push_i32_be(output: &mut Vec, value: i32) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u16_be(output: &mut Vec, value: u16) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u32_be(output: &mut Vec, value: u32) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u64_be(output: &mut Vec, value: u64) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn align_vec(output: &mut Vec, alignment: usize) { + let remainder = output.len() % alignment; + if remainder != 0 { + output.resize(output.len() + alignment - remainder, 0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Parser; + use crate::serialized::UnitySerializedFieldReplacement; + + fn synthetic_bundle(payload: &[u8]) -> Vec { + synthetic_bundle_with_path(payload, "CAB-test") + } + + fn synthetic_bundle_with_path(payload: &[u8], path: &str) -> Vec { + let mut blocks_info = vec![0; 16]; + push_i32_be(&mut blocks_info, 1); + push_u32_be(&mut blocks_info, payload.len() as u32); + push_u32_be(&mut blocks_info, payload.len() as u32); + push_u16_be(&mut blocks_info, 0); + push_i32_be(&mut blocks_info, 1); + push_u64_be(&mut blocks_info, 0); + push_u64_be(&mut blocks_info, payload.len() as u64); + push_u32_be(&mut blocks_info, 0); + push_c_string(&mut blocks_info, path); + + let mut data = Vec::new(); + push_c_string(&mut data, "UnityFS"); + push_u32_be(&mut data, 8); + push_c_string(&mut data, "5.x.x"); + push_c_string(&mut data, "2021.3.56f2"); + let total_size_offset = data.len(); + push_u64_be(&mut data, 0); + push_u32_be(&mut data, blocks_info.len() as u32); + push_u32_be(&mut data, blocks_info.len() as u32); + push_u32_be(&mut data, 0); + align_vec(&mut data, 16); + data.extend_from_slice(&blocks_info); + data.extend_from_slice(payload); + let total_size = data.len() as u64; + data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + data + } + + fn push_i16_le(data: &mut Vec, value: i16) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i32_le(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u32_le(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u64_le(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn synthetic_serialized_text_asset(bytes: &[u8]) -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 8); + object_data.extend_from_slice(b"Scenario"); + align_vec(&mut object_data, 4); + push_u32_le(&mut object_data, bytes.len() as u32); + object_data.extend_from_slice(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_vec(&mut metadata, 4); + push_u64_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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_monobehaviour() -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + align_vec(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let field_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let field_name = strings.len(); + strings.extend_from_slice(b"message\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 2); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, field_type as i32, field_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_managed_reference_registry() -> Vec { + synthetic_serialized_managed_reference_registry_with_payload_name(b"data") + } + + fn synthetic_serialized_managed_reference_registry_with_managed_reference_data() -> Vec { + synthetic_serialized_managed_reference_registry_with_payload_name(b"managedReferenceData") + } + + fn synthetic_serialized_managed_reference_registry_with_payload_name( + payload_field_name: &[u8], + ) -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 1); + object_data.extend_from_slice(&42i64.to_le_bytes()); + for value in ["ScenarioLine", "BA.Text", "Game"] { + push_u32_le(&mut object_data, value.len() as u32); + object_data.extend_from_slice(value.as_bytes()); + align_vec(&mut object_data, 4); + } + push_u32_le(&mut object_data, "こんにちは".len() as u32); + object_data.extend_from_slice("こんにちは".as_bytes()); + align_vec(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let registry_type = strings.len(); + strings.extend_from_slice(b"managedReferencesRegistry\0"); + let registry_name = strings.len(); + strings.extend_from_slice(b"m_SerializedReferences\0"); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"references\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let entry_type = strings.len(); + strings.extend_from_slice(b"ManagedReferenceEntry\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + let rid_type = strings.len(); + strings.extend_from_slice(b"long long\0"); + let rid_name = strings.len(); + strings.extend_from_slice(b"rid\0"); + let type_info_type = strings.len(); + strings.extend_from_slice(b"ManagedReferenceType\0"); + let type_info_name = strings.len(); + strings.extend_from_slice(b"type\0"); + let string_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let class_name = strings.len(); + strings.extend_from_slice(b"class\0"); + let namespace_name = strings.len(); + strings.extend_from_slice(b"ns\0"); + let assembly_name = strings.len(); + strings.extend_from_slice(b"asm\0"); + let managed_type = strings.len(); + strings.extend_from_slice(b"managedReference\0"); + let payload_name = strings.len(); + strings.extend_from_slice(payload_field_name); + strings.push(0); + let message_name = strings.len(); + strings.extend_from_slice(b"message\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 12); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, registry_type as i32, registry_name as i32), + (2u8, array_type as i32, array_name as i32), + (3u8, size_type as i32, size_name as i32), + (3u8, entry_type as i32, data_name as i32), + (4u8, rid_type as i32, rid_name as i32), + (4u8, type_info_type as i32, type_info_name as i32), + (5u8, string_type as i32, class_name as i32), + (5u8, string_type as i32, namespace_name as i32), + (5u8, string_type as i32, assembly_name as i32), + (4u8, managed_type as i32, payload_name as i32), + (5u8, string_type as i32, message_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_unknown_fixed_field() -> Vec { + let object_data = vec![1, 2, 3, 4]; + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let blob_type = strings.len(); + strings.extend_from_slice(b"CustomBlob\0"); + let blob_name = strings.len(); + strings.extend_from_slice(b"blob\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 2); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, blob_type as i32, blob_name as i32, 4), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_enum_field() -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let enum_type = strings.len(); + strings.extend_from_slice(b"ScenarioDifficulty\0"); + let enum_name = strings.len(); + strings.extend_from_slice(b"difficulty\0"); + let value_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let value_name = strings.len(); + strings.extend_from_slice(b"value__\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 3); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, enum_type as i32, enum_name as i32, -1), + (2u8, value_type as i32, value_name as i32, 4), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_bitfield_field() -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 5); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let bitfield_type = strings.len(); + strings.extend_from_slice(b"LayerMask\0"); + let bitfield_name = strings.len(); + strings.extend_from_slice(b"target_layers\0"); + let bits_type = strings.len(); + strings.extend_from_slice(b"UInt32\0"); + let bits_name = strings.len(); + strings.extend_from_slice(b"m_Bits\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 3); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, bitfield_type as i32, bitfield_name as i32, -1), + (2u8, bits_type as i32, bits_name as i32, 4), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_string_array() -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + align_vec(&mut object_data, 4); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"world"); + align_vec(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"messages\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let data_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 4); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, array_type as i32, array_name as i32), + (2u8, size_type as i32, size_name as i32), + (2u8, data_type as i32, data_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_vector_string_array() -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + align_vec(&mut object_data, 4); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"world"); + align_vec(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let vector_type = strings.len(); + strings.extend_from_slice(b"vector\0"); + let vector_name = strings.len(); + strings.extend_from_slice(b"messages\0"); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"Array\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let data_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 5); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, vector_type as i32, vector_name as i32), + (2u8, array_type as i32, array_name as i32), + (3u8, size_type as i32, size_name as i32), + (3u8, data_type as i32, data_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_int_array() -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + push_i32_le(&mut object_data, 10); + push_i32_le(&mut object_data, 20); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"scores\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let data_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 4); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, array_type as i32, array_name as i32), + (2u8, size_type as i32, size_name as i32), + (2u8, data_type as i32, data_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_string_map() -> Vec { + synthetic_serialized_string_map_with_entry_names(b"first", b"second") + } + + fn synthetic_serialized_key_value_string_map() -> Vec { + synthetic_serialized_string_map_with_entry_names(b"key", b"value") + } + + fn synthetic_serialized_scriptableobject_key_value_string_map() -> Vec { + synthetic_serialized_string_map_with_entry_names_and_root( + b"key", + b"value", + b"ScriptableObject", + 115, + ) + } + + fn synthetic_serialized_string_map_with_entry_names( + first_field_name: &[u8], + second_field_name: &[u8], + ) -> Vec { + synthetic_serialized_string_map_with_entry_names_and_root( + first_field_name, + second_field_name, + b"MonoBehaviour", + 114, + ) + } + + fn synthetic_serialized_string_map_with_entry_names_and_root( + first_field_name: &[u8], + second_field_name: &[u8], + root_type_name: &[u8], + class_id: i32, + ) -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + for (key, value) in [("jp", "hello"), ("cn", "world")] { + push_u32_le(&mut object_data, key.len() as u32); + object_data.extend_from_slice(key.as_bytes()); + align_vec(&mut object_data, 4); + push_u32_le(&mut object_data, value.len() as u32); + object_data.extend_from_slice(value.as_bytes()); + align_vec(&mut object_data, 4); + } + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(root_type_name); + strings.push(0); + let root_name = strings.len(); + strings.push(0); + let map_type = strings.len(); + strings.extend_from_slice(b"map\0"); + let map_name = strings.len(); + strings.extend_from_slice(b"texts\0"); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"Array\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let pair_type = strings.len(); + strings.extend_from_slice(b"pair\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + let string_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let first_name = strings.len(); + strings.extend_from_slice(first_field_name); + strings.push(0); + let second_name = strings.len(); + strings.extend_from_slice(second_field_name); + strings.push(0); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, class_id); + metadata.push(0); + push_i16_le(&mut metadata, 0); + if class_id == 114 { + metadata.extend_from_slice(&[0; 16]); + } + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 7); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, map_type as i32, map_name as i32), + (2u8, array_type as i32, array_name as i32), + (3u8, size_type as i32, size_name as i32), + (3u8, pair_type as i32, data_name as i32), + (4u8, string_type as i32, first_name as i32), + (4u8, string_type as i32, second_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + #[test] + fn rebuilds_uncompressed_unityfs_without_changing_directory_paths() { + let parsed = UnityFsParser::new() + .parse(&synthetic_bundle(b"payload")) + .unwrap(); + let rebuilt = rebuild_unityfs( + &UnityFsParser::new() + .parse_bytes(&synthetic_bundle(b"payload")) + .unwrap(), + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&rebuilt).unwrap(); + + assert_eq!(reparsed.directories[0].path, "CAB-test"); + assert_eq!(reparsed.files[0].data, b"payload"); + assert_eq!(parsed.unity_version, reparsed.unity_version); + } + + #[test] + fn patches_text_asset_and_verifies_reparsed_payload() { + let original_text = "こんにちは".as_bytes(); + let bundle = synthetic_bundle_with_path( + &synthetic_serialized_text_asset(original_text), + "CAB-scenario", + ); + let replacement = "你好,阿拜多斯".as_bytes().to_vec(); + let patched = patch_unityfs_text_asset( + &bundle, + &TextAssetPatch { + serialized_file_path: "CAB-scenario".to_string(), + path_id: 1, + expected_name: Some("Scenario".to_string()), + replacement: replacement.clone(), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let asset = reparsed + .text_assets + .iter() + .find(|asset| asset.path_id == 1) + .unwrap(); + + assert_eq!(reparsed.files[0].path, "CAB-scenario"); + assert_eq!(asset.name, "Scenario"); + assert_eq!(asset.bytes, replacement); + } + + #[test] + fn patches_monobehaviour_string_field_and_rebuilds_unityfs() { + let bundle = synthetic_bundle_with_path(&synthetic_serialized_monobehaviour(), "CAB-story"); + let patched = patch_unityfs_string_field( + &bundle, + &StringFieldPatch { + serialized_file_path: "CAB-story".to_string(), + path_id: 1, + field_path: "message".to_string(), + expected_value: Some("hello".to_string()), + replacement: "你好".to_string(), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + + assert_eq!(reparsed.files[0].path, "CAB-story"); + assert_eq!( + fields[0].value, + UnitySerializedValue::String("你好".to_string()) + ); + } + + #[test] + fn patches_managed_reference_payload_string_and_rebuilds_unityfs() { + let bundle = synthetic_bundle_with_path( + &synthetic_serialized_managed_reference_registry(), + "CAB-managed-story", + ); + let patched = patch_unityfs_string_field( + &bundle, + &StringFieldPatch { + serialized_file_path: "CAB-managed-story".to_string(), + path_id: 1, + field_path: "m_SerializedReferences.references[0].data.message".to_string(), + expected_value: Some("こんにちは".to_string()), + replacement: "你好".to_string(), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-managed-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry"); + }; + + assert_eq!(references.len(), 1); + assert_eq!(references[0].metadata.reference_id, Some(42)); + assert_eq!( + references[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("你好".to_string()) + ); + } + + #[test] + fn patches_managed_reference_data_payload_string_and_rebuilds_unityfs() { + let bundle = synthetic_bundle_with_path( + &synthetic_serialized_managed_reference_registry_with_managed_reference_data(), + "CAB-managed-data-story", + ); + let patched = patch_unityfs_string_field( + &bundle, + &StringFieldPatch { + serialized_file_path: "CAB-managed-data-story".to_string(), + path_id: 1, + field_path: "m_SerializedReferences.references[0].managedReferenceData.message" + .to_string(), + expected_value: Some("こんにちは".to_string()), + replacement: "你好".to_string(), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-managed-data-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry"); + }; + + assert_eq!(references.len(), 1); + assert_eq!( + references[0].fields[0].path, + "m_SerializedReferences.references[0].managedReferenceData.message" + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("你好".to_string()) + ); + } + + #[test] + fn patches_unknown_fixed_bytes_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_unknown_fixed_field(), "CAB-unknown"); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-unknown".to_string(), + path_id: 1, + field_path: "blob".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Bytes(vec![1, 2, 3, 4])), + replacement: UnitySerializedReplacementValue::Bytes(vec![9, 8, 7, 6]), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-unknown")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + + assert_eq!(reparsed.files[0].path, "CAB-unknown"); + assert_eq!( + fields[0].value, + UnitySerializedValue::Unknown { + type_name: "CustomBlob".to_string(), + bytes: vec![9, 8, 7, 6], + } + ); + } + + #[test] + fn patches_enum_field_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_enum_field(), "CAB-enum-story"); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-enum-story".to_string(), + path_id: 1, + field_path: "difficulty".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 2, + }), + replacement: UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 3, + }, + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-enum-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + + assert_eq!(reparsed.files[0].path, "CAB-enum-story"); + assert_eq!( + fields[0].value, + UnitySerializedValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 3, + } + ); + } + + #[test] + fn patches_layer_mask_bitfield_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_bitfield_field(), "CAB-mask-story"); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-mask-story".to_string(), + path_id: 1, + field_path: "target_layers".to_string(), + expected_value: Some(UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 5, + }), + replacement: UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 9, + }, + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-mask-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + + assert_eq!(reparsed.files[0].path, "CAB-mask-story"); + assert_eq!( + fields[0].value, + UnitySerializedValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 9, + } + ); + } + + #[test] + fn patches_string_array_element_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_string_array(), "CAB-array-story"); + let patched = patch_unityfs_string_field( + &bundle, + &StringFieldPatch { + serialized_file_path: "CAB-array-story".to_string(), + path_id: 1, + field_path: "messages[1]".to_string(), + expected_value: Some("world".to_string()), + replacement: "老師".to_string(), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-array-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + + assert_eq!( + items[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + } + + #[test] + fn patches_whole_string_array_with_length_change_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_string_array(), "CAB-array-story"); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-array-story".to_string(), + path_id: 1, + field_path: "messages".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("hello".to_string()), + UnitySerializedReplacementValue::String("world".to_string()), + ])), + replacement: UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + UnitySerializedReplacementValue::String("文本".to_string()), + ]), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-array-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + + assert_eq!(items.len(), 3); + assert_eq!( + items[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + assert_eq!( + items[2].value, + UnitySerializedValue::String("文本".to_string()) + ); + } + + #[test] + fn patches_nested_vector_array_with_length_change_and_rebuilds_unityfs() { + let bundle = synthetic_bundle_with_path( + &synthetic_serialized_vector_string_array(), + "CAB-vector-story", + ); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-vector-story".to_string(), + path_id: 1, + field_path: "messages".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("hello".to_string()), + UnitySerializedReplacementValue::String("world".to_string()), + ])), + replacement: UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + UnitySerializedReplacementValue::String("文本".to_string()), + ]), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-vector-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected vector string array"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!( + items[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + assert_eq!( + items[2].value, + UnitySerializedValue::String("文本".to_string()) + ); + } + + #[test] + fn patches_whole_string_map_with_length_change_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_string_map(), "CAB-map-story"); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-map-story".to_string(), + path_id: 1, + field_path: "texts".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement("jp", "hello"), + map_entry_replacement("cn", "world"), + ])), + replacement: UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement("jp", "こんにちは"), + map_entry_replacement("cn", "老師"), + map_entry_replacement("tw", "文本"), + ]), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-map-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected string map"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + assert_eq!(map_entry_strings(&items[2]), ("tw", "文本")); + } + + #[test] + fn patches_key_value_string_map_schema_and_rebuilds_unityfs() { + let bundle = synthetic_bundle_with_path( + &synthetic_serialized_key_value_string_map(), + "CAB-key-value-map", + ); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-key-value-map".to_string(), + path_id: 1, + field_path: "texts".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "hello"), + map_entry_replacement_with_names("key", "value", "cn", "world"), + ])), + replacement: UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "こんにちは"), + map_entry_replacement_with_names("key", "value", "cn", "老師"), + map_entry_replacement_with_names("key", "value", "tw", "文本"), + ]), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-key-value-map")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected key/value string map"); + }; + let UnitySerializedValue::Object(entry_fields) = &items[0].value else { + panic!("expected key/value map entry object"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(entry_fields[0].path, "texts[0].key"); + assert_eq!(entry_fields[1].path, "texts[0].value"); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + assert_eq!(map_entry_strings(&items[2]), ("tw", "文本")); + } + + #[test] + fn patches_scriptableobject_key_value_string_map_and_rebuilds_unityfs() { + let bundle = synthetic_bundle_with_path( + &synthetic_serialized_scriptableobject_key_value_string_map(), + "CAB-scriptable-map", + ); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-scriptable-map".to_string(), + path_id: 1, + field_path: "texts".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "hello"), + map_entry_replacement_with_names("key", "value", "cn", "world"), + ])), + replacement: UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "こんにちは"), + map_entry_replacement_with_names("key", "value", "cn", "老師"), + map_entry_replacement_with_names("key", "value", "tw", "文本"), + ]), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-scriptable-map")) + .unwrap(); + assert_eq!(serialized.types[0].class_id, 115); + assert_eq!(serialized.objects[0].class_id, 115); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected ScriptableObject key/value string map"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + assert_eq!(map_entry_strings(&items[2]), ("tw", "文本")); + } + + #[test] + fn patches_integer_array_element_and_rebuilds_unityfs() { + let bundle = + synthetic_bundle_with_path(&synthetic_serialized_int_array(), "CAB-score-story"); + let patched = patch_unityfs_field( + &bundle, + &FieldPatch { + serialized_file_path: "CAB-score-story".to_string(), + path_id: 1, + field_path: "scores[1]".to_string(), + expected_value: Some(UnitySerializedReplacementValue::Signed(20)), + replacement: UnitySerializedReplacementValue::Signed(42), + }, + ) + .unwrap(); + let reparsed = UnityFsParser::new().parse(&patched).unwrap(); + let serialized = reparsed + .serialized_files + .iter() + .find(|file| file.source_path.as_deref() == Some("CAB-score-story")) + .unwrap(); + let fields = serialized.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected int array"); + }; + + assert_eq!(items[0].value, UnitySerializedValue::Signed(10)); + assert_eq!(items[1].value, UnitySerializedValue::Signed(42)); + } + + fn map_entry_replacement(key: &str, value: &str) -> UnitySerializedReplacementValue { + map_entry_replacement_with_names("first", "second", key, value) + } + + fn map_entry_replacement_with_names( + key_name: &str, + value_name: &str, + key: &str, + value: &str, + ) -> UnitySerializedReplacementValue { + UnitySerializedReplacementValue::Object(vec![ + UnitySerializedFieldReplacement { + name: key_name.to_string(), + value: UnitySerializedReplacementValue::String(key.to_string()), + }, + UnitySerializedFieldReplacement { + name: value_name.to_string(), + value: UnitySerializedReplacementValue::String(value.to_string()), + }, + ]) + } + + fn map_entry_strings(entry: &UnitySerializedField) -> (&str, &str) { + let UnitySerializedValue::Object(fields) = &entry.value else { + panic!("expected map entry object"); + }; + let UnitySerializedValue::String(key) = &fields[0].value else { + panic!("expected string key"); + }; + let UnitySerializedValue::String(value) = &fields[1].value else { + panic!("expected string value"); + }; + (key, value) + } +} diff --git a/crates/bat-assetbundle/src/serialized.rs b/crates/bat-assetbundle/src/serialized.rs index 2f55cd9..c620968 100644 --- a/crates/bat-assetbundle/src/serialized.rs +++ b/crates/bat-assetbundle/src/serialized.rs @@ -9,6 +9,529 @@ use crate::error::{AssetBundleError, Result}; use std::fs; use std::path::Path; +/// Value decoded from a Unity serialized TypeTree node. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", content = "value")] +pub enum UnitySerializedValue { + /// Boolean value. + Bool(bool), + /// Signed integer value. + Signed(i64), + /// Unsigned integer value. + Unsigned(u64), + /// IEEE-754 single precision value stored as raw bits. + Float32(u32), + /// IEEE-754 double precision value stored as raw bits. + Float64(u64), + /// UTF-8 string value. + String(String), + /// Raw byte sequence. + Bytes(Vec), + /// Fixed-size Unity value type made of IEEE-754 single precision raw bits, + /// for example `Vector3f`, `ColorRGBA`, `Quaternionf`, `Rectf` or `AABB`. + Float32Struct { + /// TypeTree type name. + type_name: String, + /// Raw `f32::to_bits()` values in serialized field order. + values: Vec, + }, + /// Fixed-size Unity value type made of signed 32-bit integer components, + /// for example `Vector2Int`, `Vector3Int`, `RectInt` or `BoundsInt`. + Int32Struct { + /// TypeTree type name. + type_name: String, + /// Signed integer values in serialized field order. + values: Vec, + }, + /// Fixed-size Unity byte value type, for example `GUID` or `Hash128`. + FixedBytes { + /// TypeTree type name. + type_name: String, + /// Raw bytes in serialized field order. + bytes: Vec, + }, + /// Unity enum value decoded through a TypeTree `value__` child. + Enum { + /// TypeTree enum type name. + type_name: String, + /// Backing integer storage type, for example `int` or `UInt32`. + storage_type: String, + /// Signed enum value. + value: i64, + }, + /// Unity bit field value decoded through a `m_Bits`/`bits` child, for + /// example `LayerMask` or `BitField`. + BitField { + /// TypeTree bit field type name. + type_name: String, + /// Backing integer storage type, for example `int` or `UInt32`. + storage_type: String, + /// Bit mask value. + bits: i64, + }, + /// Pointer to an object in the same or another serialized file. + PPtr { + /// Referenced serialized file identifier. + file_id: i32, + /// Referenced Unity path ID. + path_id: i64, + }, + /// Repeated fields decoded from an array/vector node. + Array(Vec), + /// Repeated pair/object fields decoded from a map node. + Map(Vec), + /// Nested object fields. + Object(Vec), + /// Managed-reference payload decoded from TypeTree-covered fields or + /// retained as fixed-size raw bytes. + ManagedReference { + /// TypeTree type name for the managed-reference node. + type_name: String, + /// Best-effort metadata decoded from TypeTree-covered registry fields. + #[serde(default, skip_serializing_if = "Option::is_none")] + metadata: Option, + /// Decoded managed-reference fields. + fields: Vec, + /// Raw bytes retained when the TypeTree node has no children but a + /// fixed byte size. + bytes: Vec, + }, + /// Unity managed-reference registry decoded from TypeTree-covered fields. + ManagedReferenceRegistry { + /// Registry records inferred from `references` array entries. + references: Vec, + /// Decoded raw registry fields. These preserve all original field paths + /// and are used for text extraction and field patch lookup. + fields: Vec, + }, + /// Bytes retained when a node has a declared fixed size but no known + /// primitive or child-node decoder. + Unknown { + /// TypeTree type name. + type_name: String, + /// Raw bytes belonging to the node. + bytes: Vec, + }, +} + +/// Best-effort metadata for one managed reference. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct UnityManagedReferenceMetadata { + /// Unity managed-reference ID, when present in TypeTree-covered fields. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference_id: Option, + /// Raw managed full type name, when Unity stores it as one combined field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_type_name: Option, + /// Managed class or concrete type name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub type_name: Option, + /// Managed namespace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Managed assembly name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assembly_name: Option, +} + +impl UnityManagedReferenceMetadata { + fn is_empty(&self) -> bool { + self.reference_id.is_none() + && self.full_type_name.is_none() + && self.type_name.is_none() + && self.namespace.is_none() + && self.assembly_name.is_none() + } +} + +/// One managed-reference registry entry. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct UnityManagedReferenceRecord { + /// Registry metadata decoded from record fields. + pub metadata: UnityManagedReferenceMetadata, + /// Payload fields for this reference, usually the `data` child. + pub fields: Vec, +} + +/// Semantic replacement value for a decoded Unity TypeTree field. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum UnitySerializedReplacementValue { + /// Boolean value. + Bool(bool), + /// Signed integer value. + Signed(i64), + /// Unsigned integer value. + Unsigned(u64), + /// IEEE-754 single precision raw bits. + Float32(u32), + /// IEEE-754 double precision raw bits. + Float64(u64), + /// UTF-8 string value. + String(String), + /// Raw byte sequence for TypelessData/bytes nodes. + Bytes(Vec), + /// Fixed-size Unity value type made of IEEE-754 single precision raw bits. + Float32Struct { + /// TypeTree type name. + type_name: String, + /// Raw `f32::to_bits()` values in serialized field order. + values: Vec, + }, + /// Fixed-size Unity value type made of signed 32-bit integer components. + Int32Struct { + /// TypeTree type name. + type_name: String, + /// Signed integer values in serialized field order. + values: Vec, + }, + /// Fixed-size Unity byte value type. + FixedBytes { + /// TypeTree type name. + type_name: String, + /// Raw bytes in serialized field order. + bytes: Vec, + }, + /// Unity enum replacement value. + Enum { + /// TypeTree enum type name. + type_name: String, + /// Backing integer storage type, for example `int` or `UInt32`. + storage_type: String, + /// Signed enum value. + value: i64, + }, + /// Unity bit field replacement value. + BitField { + /// TypeTree bit field type name. + type_name: String, + /// Backing integer storage type, for example `int` or `UInt32`. + storage_type: String, + /// Bit mask value. + bits: i64, + }, + /// Pointer to an object in the same or another serialized file. + PPtr { + /// Referenced serialized file identifier. + file_id: i32, + /// Referenced Unity path ID. + path_id: i64, + }, + /// Whole-array replacement. Existing items or the TypeTree data node are + /// used as the element encoding schema, so length changes are supported + /// even when the current array is empty. + Array(Vec), + /// Whole-map replacement. Existing entries or the TypeTree data node are + /// used as the entry encoding schema. + Map(Vec), + /// Replacement for one TypeTree object value. + Object(Vec), +} + +/// One named child replacement inside a TypeTree object. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct UnitySerializedFieldReplacement { + /// TypeTree child field name, for example `first`, `second` or `message`. + pub name: String, + /// Replacement value for that child. + pub value: UnitySerializedReplacementValue, +} + +impl UnitySerializedReplacementValue { + /// Returns true when this semantic value matches a decoded field value. + pub fn matches_serialized_value(&self, value: &UnitySerializedValue) -> bool { + matches!( + (self, value), + (Self::Bool(expected), UnitySerializedValue::Bool(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::Signed(expected), UnitySerializedValue::Signed(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::Unsigned(expected), UnitySerializedValue::Unsigned(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::Float32(expected), UnitySerializedValue::Float32(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::Float64(expected), UnitySerializedValue::Float64(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::String(expected), UnitySerializedValue::String(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::Bytes(expected), UnitySerializedValue::Bytes(actual)) if expected == actual + ) || matches!( + (self, value), + (Self::Bytes(expected), UnitySerializedValue::Unknown { bytes: actual, .. }) if expected == actual + ) || matches!( + (self, value), + ( + Self::Float32Struct { + type_name: expected_type, + values: expected_values, + }, + UnitySerializedValue::Float32Struct { + type_name: actual_type, + values: actual_values, + }, + ) if normalized_metadata_key(expected_type) == normalized_metadata_key(actual_type) + && expected_values == actual_values + ) || matches!( + self, + Self::Float32Struct { + type_name: expected_type, + values: expected_values, + } if object_value_matches_float32_struct(value, expected_type, expected_values) + ) || matches!( + (self, value), + ( + Self::Int32Struct { + type_name: expected_type, + values: expected_values, + }, + UnitySerializedValue::Int32Struct { + type_name: actual_type, + values: actual_values, + }, + ) if normalized_metadata_key(expected_type) == normalized_metadata_key(actual_type) + && expected_values == actual_values + ) || matches!( + self, + Self::Int32Struct { + type_name: expected_type, + values: expected_values, + } if object_value_matches_int32_struct(value, expected_type, expected_values) + ) || matches!( + (self, value), + ( + Self::FixedBytes { + type_name: expected_type, + bytes: expected_bytes, + }, + UnitySerializedValue::FixedBytes { + type_name: actual_type, + bytes: actual_bytes, + }, + ) if normalized_metadata_key(expected_type) == normalized_metadata_key(actual_type) + && expected_bytes == actual_bytes + ) || matches!( + self, + Self::FixedBytes { + type_name: expected_type, + bytes: expected_bytes, + } if object_value_matches_fixed_bytes(value, expected_type, expected_bytes) + ) || matches!( + ( + self, + value, + ), + ( + Self::Enum { + type_name: expected_type, + storage_type: expected_storage, + value: expected_value, + }, + UnitySerializedValue::Enum { + type_name: actual_type, + storage_type: actual_storage, + value: actual_value, + }, + ) if normalized_metadata_key(expected_type) == normalized_metadata_key(actual_type) + && normalized_metadata_key(expected_storage) + == normalized_metadata_key(actual_storage) + && expected_value == actual_value + ) || matches!( + ( + self, + value, + ), + ( + Self::BitField { + type_name: expected_type, + storage_type: expected_storage, + bits: expected_bits, + }, + UnitySerializedValue::BitField { + type_name: actual_type, + storage_type: actual_storage, + bits: actual_bits, + }, + ) if normalized_metadata_key(expected_type) == normalized_metadata_key(actual_type) + && normalized_metadata_key(expected_storage) + == normalized_metadata_key(actual_storage) + && expected_bits == actual_bits + ) || matches!( + (self, value), + ( + Self::PPtr { + file_id: expected_file_id, + path_id: expected_path_id, + }, + UnitySerializedValue::PPtr { + file_id: actual_file_id, + path_id: actual_path_id, + }, + ) if expected_file_id == actual_file_id && expected_path_id == actual_path_id + ) || matches!( + (self, value), + ( + Self::Array(expected_items), + UnitySerializedValue::Array(actual_items), + ) if expected_items.len() == actual_items.len() + && expected_items + .iter() + .zip(actual_items) + .all(|(expected, actual)| expected.matches_serialized_value(&actual.value)) + ) || matches!( + (self, value), + ( + Self::Map(expected_items), + UnitySerializedValue::Map(actual_items), + ) if expected_items.len() == actual_items.len() + && expected_items + .iter() + .zip(actual_items) + .all(|(expected, actual)| expected.matches_serialized_value(&actual.value)) + ) || matches!( + (self, value), + (Self::Object(expected_fields), UnitySerializedValue::Object(actual_fields)) + if replacement_fields_match_serialized_fields(expected_fields, actual_fields) + ) || matches!( + (self, value), + ( + Self::Object(expected_fields), + UnitySerializedValue::ManagedReference { + fields: actual_fields, + .. + }, + ) if replacement_fields_match_serialized_fields(expected_fields, actual_fields) + ) || matches!( + (self, value), + ( + Self::Object(expected_fields), + UnitySerializedValue::ManagedReferenceRegistry { + fields: actual_fields, + .. + }, + ) if replacement_fields_match_serialized_fields(expected_fields, actual_fields) + ) + } +} + +fn replacement_fields_match_serialized_fields( + expected_fields: &[UnitySerializedFieldReplacement], + actual_fields: &[UnitySerializedField], +) -> bool { + expected_fields.iter().all(|expected| { + actual_fields + .iter() + .find(|actual| actual.name == expected.name) + .is_some_and(|actual| expected.value.matches_serialized_value(&actual.value)) + }) +} + +fn object_value_matches_float32_struct( + value: &UnitySerializedValue, + expected_type: &str, + expected_values: &[u32], +) -> bool { + object_float32_struct_values(value, expected_type) + .is_some_and(|actual_values| actual_values == expected_values) +} + +fn object_value_matches_int32_struct( + value: &UnitySerializedValue, + expected_type: &str, + expected_values: &[i32], +) -> bool { + object_int32_struct_values(value, expected_type) + .is_some_and(|actual_values| actual_values == expected_values) +} + +fn object_value_matches_fixed_bytes( + value: &UnitySerializedValue, + expected_type: &str, + expected_bytes: &[u8], +) -> bool { + object_fixed_bytes_value(value, expected_type) + .is_some_and(|actual_bytes| actual_bytes == expected_bytes) +} + +fn object_float32_struct_values(value: &UnitySerializedValue, type_name: &str) -> Option> { + let component_count = unity_float32_struct_component_count(type_name)?; + let UnitySerializedValue::Object(fields) = value else { + return None; + }; + if fields.len() != component_count { + return None; + } + fields + .iter() + .map(|field| match field.value { + UnitySerializedValue::Float32(value) => Some(value), + _ => None, + }) + .collect() +} + +fn object_int32_struct_values(value: &UnitySerializedValue, type_name: &str) -> Option> { + let component_count = unity_int32_struct_component_count(type_name)?; + let UnitySerializedValue::Object(fields) = value else { + return None; + }; + if fields.len() != component_count { + return None; + } + fields + .iter() + .map(|field| match field.value { + UnitySerializedValue::Signed(value) => i32::try_from(value).ok(), + _ => None, + }) + .collect() +} + +fn object_fixed_bytes_value(value: &UnitySerializedValue, type_name: &str) -> Option> { + let byte_size = unity_fixed_bytes_size(type_name)?; + let UnitySerializedValue::Object(fields) = value else { + return None; + }; + if fields.len() != byte_size { + return None; + } + fields + .iter() + .map(|field| match field.value { + UnitySerializedValue::Unsigned(value) => u8::try_from(value).ok(), + UnitySerializedValue::Signed(value) => u8::try_from(value).ok(), + _ => None, + }) + .collect() +} + +/// One field decoded from a Unity serialized object. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct UnitySerializedField { + /// Stable path relative to the serialized object, for example + /// `config.entries[0].message`. + pub path: String, + /// TypeTree field name. + pub name: String, + /// TypeTree field type name. + pub type_name: String, + /// Byte offset relative to the beginning of the object payload. + pub offset: usize, + /// Number of bytes consumed by this field, including alignment padding. + pub byte_size: usize, + /// Index into the source TypeTree node table, when the field was decoded + /// from an embedded TypeTree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub type_tree_node_index: Option, + /// Decoded value. + pub value: UnitySerializedValue, +} + /// One extracted Unity `TextAsset`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnitySerializedTextAsset { @@ -37,6 +560,14 @@ pub struct UnitySerializedFile { pub types: Vec, /// Object table entries declared by the file. pub objects: Vec, + /// Serialized file bytes retained for field-level object decoding. + raw_data: Vec, + /// Absolute offset of the serialized object data section. + data_offset: usize, + /// Absolute byte offsets of object table entries in metadata order. + object_table_offsets: Vec, + /// Byte order used by serialized metadata and object data. + endian: Endian, text_assets: Vec, } @@ -86,6 +617,12 @@ impl UnitySerializedFile { data.len() ))); } + let data_offset_usize = usize::try_from(data_offset).map_err(|_| { + AssetBundleError::Parse(format!( + "Unity serialized data_offset {} does not fit usize", + data_offset + )) + })?; let metadata_end = reader .offset() .checked_add(metadata_size as usize) @@ -141,11 +678,13 @@ impl UnitySerializedFile { } let mut objects = Vec::with_capacity(object_count as usize); + let mut object_table_offsets = Vec::with_capacity(object_count as usize); let mut text_assets = Vec::new(); for _ in 0..object_count { if version >= 14 { reader.align(4)?; } + object_table_offsets.push(reader.offset()); let path_id = if big_id_enabled != 0 { reader.read_i64("path_id")? @@ -228,6 +767,10 @@ impl UnitySerializedFile { platform, types, objects, + raw_data: data.to_vec(), + data_offset: data_offset_usize, + object_table_offsets, + endian, text_assets, }) } @@ -248,6 +791,401 @@ impl UnitySerializedFile { pub fn text_asset(&self, name: &str) -> Option<&UnitySerializedTextAsset> { self.text_assets.iter().find(|asset| asset.name == name) } + + /// Returns true when the object references an embedded TypeTree. + pub fn object_has_type_tree(&self, object: &UnitySerializedObject) -> bool { + self.types + .get(object.type_index) + .is_some_and(|type_info| !type_info.type_tree.is_empty()) + } + + /// Replaces one TextAsset payload and rewrites the serialized object table. + /// + /// The serialized file header, metadata layout and byte order are + /// preserved. Object payload size may change; subsequent object offsets + /// are updated accordingly. Versions before 14 are rejected because their + /// object table uses a different path-ID layout. + pub fn replace_text_asset( + &self, + path_id: i64, + expected_name: Option<&str>, + replacement: &[u8], + ) -> Result> { + let target_index = self + .objects + .iter() + .position(|object| object.path_id == path_id && object.class_id == 49) + .ok_or_else(|| { + AssetBundleError::Parse(format!("TextAsset object path_id {path_id} not found")) + })?; + let text_asset = self + .text_assets + .iter() + .find(|asset| asset.path_id == path_id) + .ok_or_else(|| { + AssetBundleError::Parse(format!( + "TextAsset payload path_id {path_id} was not extracted" + )) + })?; + if expected_name.is_some_and(|name| name != text_asset.name) { + return Err(AssetBundleError::Parse(format!( + "TextAsset path_id {path_id} name mismatch: expected {:?}, actual {:?}", + expected_name, text_asset.name + ))); + } + + let replacement_object = encode_text_asset(&text_asset.name, replacement, self.endian)?; + self.rewrite_object_payload(target_index, replacement_object) + } + + /// Replaces one TypeTree string field and rewrites the serialized object. + /// + /// The field is looked up by its stable `field_path` in the decoded + /// TypeTree tree. This supports MonoBehaviour, ScriptableObject and + /// managed-reference children as long as the field is a UTF-8 string node. + pub fn replace_string_field( + &self, + path_id: i64, + field_path: &str, + expected_value: Option<&str>, + replacement: &str, + ) -> Result> { + let object_index = self + .objects + .iter() + .position(|object| object.path_id == path_id) + .ok_or_else(|| { + AssetBundleError::Parse(format!("Unity object path_id {path_id} not found")) + })?; + let object = &self.objects[object_index]; + let fields = self.fields_for_object_entry(object)?; + let field = find_field_by_path(&fields, field_path).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} field {} not found", + path_id, field_path + )) + })?; + let current = match &field.value { + UnitySerializedValue::String(text) => text, + other => { + return Err(AssetBundleError::UnsupportedFormat(format!( + "Unity object path_id {} field {} is not a string field (found {:?})", + path_id, field_path, other + ))); + } + }; + if expected_value.is_some_and(|expected| expected != current) { + return Err(AssetBundleError::Parse(format!( + "Unity object path_id {} field {} mismatch: expected {:?}, actual {:?}", + path_id, field_path, expected_value, current + ))); + } + let expected_encoded = encode_aligned_string(current, self.endian)?; + let encoded = encode_aligned_string(replacement, self.endian)?; + self.replace_field_bytes(path_id, field_path, Some(&expected_encoded), &encoded) + } + + /// Replaces one decoded TypeTree field with a semantic value. + /// + /// This supports primitive values, `string`, `TypelessData`/`bytes`, + /// `PPtr`, object fields and TypeTree-covered array/map containers. + pub fn replace_field_value( + &self, + path_id: i64, + field_path: &str, + expected_value: Option<&UnitySerializedReplacementValue>, + replacement: &UnitySerializedReplacementValue, + ) -> Result> { + let object_index = self + .objects + .iter() + .position(|object| object.path_id == path_id) + .ok_or_else(|| { + AssetBundleError::Parse(format!("Unity object path_id {path_id} not found")) + })?; + let object = &self.objects[object_index]; + let type_info = self.types.get(object.type_index).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} references missing type index {}", + object.path_id, object.type_index + )) + })?; + let fields = self.fields_for_object_entry(object)?; + let field = find_field_by_path(&fields, field_path).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} field {} not found", + path_id, field_path + )) + })?; + if expected_value.is_some_and(|expected| !expected.matches_serialized_value(&field.value)) { + return Err(AssetBundleError::Parse(format!( + "Unity object path_id {} field {} value mismatch", + path_id, field_path + ))); + } + let context = ReplacementEncodingContext { + serialized_version: self.version, + endian: self.endian, + nodes: Some(&type_info.type_tree), + }; + let encoded = encode_replacement_value( + replacement, + &field.value, + &field.type_name, + field.byte_size, + field.type_tree_node_index, + context, + )?; + self.replace_field_bytes(path_id, field_path, None, &encoded) + } + + /// Replaces one decoded TypeTree field with raw bytes and rewrites the object. + pub fn replace_field_bytes( + &self, + path_id: i64, + field_path: &str, + expected_bytes: Option<&[u8]>, + replacement: &[u8], + ) -> Result> { + let object_index = self + .objects + .iter() + .position(|object| object.path_id == path_id) + .ok_or_else(|| { + AssetBundleError::Parse(format!("Unity object path_id {path_id} not found")) + })?; + let object = &self.objects[object_index]; + let fields = self.fields_for_object_entry(object)?; + let field = find_field_by_path(&fields, field_path).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} field {} not found", + path_id, field_path + )) + })?; + let object_start = self + .data_offset + .checked_add(usize::try_from(object.byte_start).map_err(|_| { + AssetBundleError::Parse(format!( + "Unity object path_id {} byte_start does not fit usize", + object.path_id + )) + })?) + .ok_or_else(|| AssetBundleError::Parse("Unity object offset overflow".to_string()))?; + let object_end = object_start + .checked_add(object.byte_size as usize) + .ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?; + let object_data = self.raw_data.get(object_start..object_end).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} byte range {}..{} exceeds file size {}", + object.path_id, + object_start, + object_end, + self.raw_data.len() + )) + })?; + let start = field.offset; + let end = start + .checked_add(field.byte_size) + .ok_or_else(|| AssetBundleError::Parse("field range overflow".to_string()))?; + if end > object_data.len() { + return Err(AssetBundleError::Parse(format!( + "Unity object path_id {} field {} range {}..{} exceeds object size {}", + object.path_id, + field_path, + start, + end, + object_data.len() + ))); + } + if expected_bytes.is_some_and(|expected| expected != &object_data[start..end]) { + return Err(AssetBundleError::Parse(format!( + "Unity object path_id {} field {} byte mismatch", + path_id, field_path + ))); + } + let mut replacement_object = Vec::with_capacity( + object_data + .len() + .saturating_sub(field.byte_size) + .saturating_add(replacement.len()), + ); + replacement_object.extend_from_slice(&object_data[..start]); + replacement_object.extend_from_slice(replacement); + replacement_object.extend_from_slice(&object_data[end..]); + self.rewrite_object_payload(object_index, replacement_object) + } + + /// Decodes the TypeTree fields for one object. + /// + /// `TextAsset` extraction remains available through [`Self::text_assets`]. + /// This method is intended for `MonoBehaviour`, `ScriptableObject` and + /// other objects whose file contains a TypeTree. It never guesses a + /// layout when the TypeTree is absent or a node type is unknown. + pub fn fields_for_object(&self, path_id: i64) -> Result> { + let object = self + .objects + .iter() + .find(|object| object.path_id == path_id) + .ok_or_else(|| { + AssetBundleError::Parse(format!("Unity object path_id {path_id} not found")) + })?; + self.fields_for_object_entry(object) + } + + /// Decodes the TypeTree fields for an object table entry. + pub fn fields_for_object_entry( + &self, + object: &UnitySerializedObject, + ) -> Result> { + let type_info = self.types.get(object.type_index).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} references missing type index {}", + object.path_id, object.type_index + )) + })?; + if type_info.type_tree.is_empty() { + return Err(AssetBundleError::UnsupportedFormat(format!( + "Unity object path_id {} class_id {} has no TypeTree", + object.path_id, object.class_id + ))); + } + + let object_start = self + .data_offset + .checked_add(usize::try_from(object.byte_start).map_err(|_| { + AssetBundleError::Parse(format!( + "Unity object path_id {} byte_start does not fit usize", + object.path_id + )) + })?) + .ok_or_else(|| AssetBundleError::Parse("Unity object offset overflow".to_string()))?; + let object_end = object_start + .checked_add(object.byte_size as usize) + .ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?; + let object_data = self.raw_data.get(object_start..object_end).ok_or_else(|| { + AssetBundleError::Parse(format!( + "Unity object path_id {} byte range {}..{} exceeds file size {}", + object.path_id, + object_start, + object_end, + self.raw_data.len() + )) + })?; + + let mut decoder = FieldDecoder::new(object_data, self.version, self.endian); + let root = decoder.decode_node(&type_info.type_tree, 0, String::new())?; + match root.value { + UnitySerializedValue::Object(fields) => Ok(fields), + value => Ok(vec![UnitySerializedField { + path: root.name.clone(), + name: root.name, + type_name: root.type_name, + offset: root.offset, + byte_size: root.byte_size, + type_tree_node_index: root.type_tree_node_index, + value, + }]), + } + } + + fn rewrite_object_payload( + &self, + target_index: usize, + replacement_object: Vec, + ) -> Result> { + if self.version < 14 { + return Err(AssetBundleError::UnsupportedFormat(format!( + "serialized object payload rewriting does not support version {}", + self.version + ))); + } + let original_data = self.raw_data.get(self.data_offset..).ok_or_else(|| { + AssetBundleError::Parse("serialized data offset is invalid".to_string()) + })?; + let mut ordered_objects = self.objects.iter().enumerate().collect::>(); + ordered_objects.sort_by_key(|(_, object)| object.byte_start); + + let mut rewritten_data = Vec::with_capacity( + original_data + .len() + .saturating_sub(self.objects[target_index].byte_size as usize) + .saturating_add(replacement_object.len()), + ); + let mut new_offsets = vec![0usize; self.objects.len()]; + let mut cursor = 0usize; + for (index, object) in ordered_objects { + let start = usize::try_from(object.byte_start).map_err(|_| { + AssetBundleError::Parse(format!( + "object path_id {} byte_start does not fit usize", + object.path_id + )) + })?; + let end = start + .checked_add(object.byte_size as usize) + .ok_or_else(|| AssetBundleError::Parse("object range overflow".to_string()))?; + if start < cursor || end > original_data.len() { + return Err(AssetBundleError::Parse(format!( + "object path_id {} range {}..{} is invalid", + object.path_id, start, end + ))); + } + rewritten_data.extend_from_slice(&original_data[cursor..start]); + new_offsets[index] = rewritten_data.len(); + if index == target_index { + rewritten_data.extend_from_slice(&replacement_object); + } else { + rewritten_data.extend_from_slice(&original_data[start..end]); + } + cursor = end; + } + rewritten_data.extend_from_slice(&original_data[cursor..]); + + let mut output = self.raw_data[..self.data_offset].to_vec(); + output.extend_from_slice(&rewritten_data); + let file_size = u64::try_from(output.len()) + .map_err(|_| AssetBundleError::Parse("rewritten file size overflow".to_string()))?; + if self.version >= 22 { + write_u64_be(&mut output, 24, file_size)?; + } else { + let file_size = u32::try_from(file_size).map_err(|_| { + AssetBundleError::Parse("rewritten legacy file exceeds u32 size".to_string()) + })?; + write_u32_be(&mut output, 4, file_size)?; + } + + for (index, object) in self.objects.iter().enumerate() { + let entry_offset = *self.object_table_offsets.get(index).ok_or_else(|| { + AssetBundleError::Parse(format!( + "missing object table offset for path_id {}", + object.path_id + )) + })?; + let byte_size = if index == target_index { + replacement_object.len() + } else { + object.byte_size as usize + }; + let byte_size = u32::try_from(byte_size).map_err(|_| { + AssetBundleError::Parse("rewritten object exceeds u32 size".to_string()) + })?; + if self.version >= 22 { + write_u64_endian( + &mut output, + entry_offset + 8, + new_offsets[index] as u64, + self.endian, + )?; + write_u32_endian(&mut output, entry_offset + 16, byte_size, self.endian)?; + } else { + let byte_start = u32::try_from(new_offsets[index]).map_err(|_| { + AssetBundleError::Parse("rewritten object offset exceeds u32".to_string()) + })?; + write_u32_endian(&mut output, entry_offset + 8, byte_start, self.endian)?; + write_u32_endian(&mut output, entry_offset + 12, byte_size, self.endian)?; + } + } + Ok(output) + } } /// Type metadata entry from a Unity serialized file. @@ -307,6 +1245,1061 @@ pub struct UnitySerializedObject { pub class_id: i32, } +struct FieldDecoder<'a> { + reader: Reader<'a>, + serialized_version: u32, +} + +impl<'a> FieldDecoder<'a> { + fn new(data: &'a [u8], serialized_version: u32, endian: Endian) -> Self { + let mut reader = Reader::new(data); + reader.set_endian(endian); + Self { + reader, + serialized_version, + } + } + + fn decode_node( + &mut self, + nodes: &[UnityTypeTreeNode], + index: usize, + path: String, + ) -> Result { + let node = nodes.get(index).ok_or_else(|| { + AssetBundleError::Parse(format!("TypeTree node index {index} is out of range")) + })?; + let start = self.reader.offset(); + let value = self.decode_value(nodes, index, path.clone())?; + if node.meta_flag & TYPE_TREE_ALIGN_BYTES != 0 { + self.reader.align(4)?; + } + let end = self.reader.offset(); + Ok(UnitySerializedField { + path, + name: node.name.clone(), + type_name: node.type_name.clone(), + offset: start, + byte_size: end.saturating_sub(start), + type_tree_node_index: Some(index), + value, + }) + } + + fn decode_value( + &mut self, + nodes: &[UnityTypeTreeNode], + index: usize, + path: String, + ) -> Result { + let node = nodes.get(index).ok_or_else(|| { + AssetBundleError::Parse(format!("TypeTree node index {index} is out of range")) + })?; + let end = node_end(nodes, index); + let children = direct_children(nodes, index, end); + + if node.type_name == "map" { + return self.decode_map(nodes, &children, path); + } + if is_vector_container_node(node) { + let array_children = collection_array_children(nodes, index); + return self.decode_array(nodes, index, &array_children, path, false); + } + if is_array_node(node) { + return self.decode_array(nodes, index, &children, path, false); + } + if node.type_name.starts_with("PPtr<") || node.type_name == "PPtr" { + return self.decode_pptr(); + } + if let Some(bits_child_index) = bitfield_bits_child_index(nodes, &children, node) { + return self.decode_bitfield(nodes, bits_child_index, path, node); + } + if let Some(value_child_index) = enum_value_child_index(nodes, &children) { + return self.decode_enum(nodes, value_child_index, path, node); + } + if is_managed_reference_registry_node(node) { + return self.decode_managed_reference_registry(nodes, &children, path); + } + if is_managed_reference_node(node) { + return self.decode_managed_reference(nodes, &children, path, node); + } + if children.is_empty() { + if let Some(component_count) = unity_float32_struct_component_count(&node.type_name) { + return self.decode_float32_struct(path, node, component_count); + } + if let Some(component_count) = unity_int32_struct_component_count(&node.type_name) { + return self.decode_int32_struct(path, node, component_count); + } + if let Some(byte_size) = unity_fixed_bytes_size(&node.type_name) { + return self.decode_fixed_bytes(path, node, byte_size); + } + } + + match node.type_name.as_str() { + "bool" => Ok(UnitySerializedValue::Bool(self.reader.read_u8(&path)? != 0)), + "char" | "SInt8" => Ok(UnitySerializedValue::Signed( + self.reader.read_i8(&path)? as i64 + )), + "UInt8" | "byte" => Ok(UnitySerializedValue::Unsigned(u64::from( + self.reader.read_u8(&path)?, + ))), + "short" | "SInt16" => Ok(UnitySerializedValue::Signed(i64::from( + self.reader.read_i16(&path)?, + ))), + "UInt16" | "unsigned short" => Ok(UnitySerializedValue::Unsigned(u64::from( + self.reader.read_u16(&path)?, + ))), + "int" | "SInt32" => Ok(UnitySerializedValue::Signed(i64::from( + self.reader.read_i32(&path)?, + ))), + "UInt32" | "unsigned int" => Ok(UnitySerializedValue::Unsigned(u64::from( + self.reader.read_u32(&path)?, + ))), + "long long" | "SInt64" => { + Ok(UnitySerializedValue::Signed(self.reader.read_i64(&path)?)) + } + "UInt64" | "unsigned long long" => { + Ok(UnitySerializedValue::Unsigned(self.reader.read_u64(&path)?)) + } + "float" => Ok(UnitySerializedValue::Float32(self.reader.read_u32(&path)?)), + "double" => Ok(UnitySerializedValue::Float64(self.reader.read_u64(&path)?)), + "string" => Ok(UnitySerializedValue::String( + self.reader.read_aligned_string(&path)?, + )), + "TypelessData" | "bytes" => { + let size = self.reader.read_u32(&path)? as usize; + Ok(UnitySerializedValue::Bytes( + self.reader.read_bytes(size, &path)?.to_vec(), + )) + } + _ if !children.is_empty() => { + let mut fields = Vec::with_capacity(children.len()); + for child_index in children { + let child = &nodes[child_index]; + let child_path = child_path(&path, &child.name); + fields.push(self.decode_node(nodes, child_index, child_path)?); + } + Ok(UnitySerializedValue::Object(fields)) + } + _ if node.byte_size >= 0 => { + let size = usize::try_from(node.byte_size).map_err(|_| { + AssetBundleError::parse_field( + &path, + self.reader.offset(), + "TypeTree byte_size does not fit usize", + ) + })?; + Ok(UnitySerializedValue::Unknown { + type_name: node.type_name.clone(), + bytes: self.reader.read_bytes(size, &path)?.to_vec(), + }) + } + _ => Err(AssetBundleError::parse_field( + &path, + self.reader.offset(), + format!("unsupported TypeTree node type {}", node.type_name), + )), + } + } + + fn decode_float32_struct( + &mut self, + path: String, + node: &UnityTypeTreeNode, + component_count: usize, + ) -> Result { + validate_fixed_leaf_byte_size(&path, self.reader.offset(), node, component_count * 4)?; + let mut values = Vec::with_capacity(component_count); + for _ in 0..component_count { + values.push(self.reader.read_u32(&path)?); + } + Ok(UnitySerializedValue::Float32Struct { + type_name: node.type_name.clone(), + values, + }) + } + + fn decode_int32_struct( + &mut self, + path: String, + node: &UnityTypeTreeNode, + component_count: usize, + ) -> Result { + validate_fixed_leaf_byte_size(&path, self.reader.offset(), node, component_count * 4)?; + let mut values = Vec::with_capacity(component_count); + for _ in 0..component_count { + values.push(self.reader.read_i32(&path)?); + } + Ok(UnitySerializedValue::Int32Struct { + type_name: node.type_name.clone(), + values, + }) + } + + fn decode_fixed_bytes( + &mut self, + path: String, + node: &UnityTypeTreeNode, + byte_size: usize, + ) -> Result { + validate_fixed_leaf_byte_size(&path, self.reader.offset(), node, byte_size)?; + Ok(UnitySerializedValue::FixedBytes { + type_name: node.type_name.clone(), + bytes: self.reader.read_bytes(byte_size, &path)?.to_vec(), + }) + } + + fn decode_array( + &mut self, + nodes: &[UnityTypeTreeNode], + array_index: usize, + children: &[usize], + path: String, + is_map: bool, + ) -> Result { + let size = self.reader.read_i32(&path)?; + if size < 0 { + return Err(AssetBundleError::parse_field( + &path, + self.reader.offset().saturating_sub(4), + format!("negative array size {size}"), + )); + } + let size = usize::try_from(size).map_err(|_| { + AssetBundleError::parse_field(&path, self.reader.offset(), "array size overflow") + })?; + if size > MAX_COLLECTION_ITEMS { + return Err(AssetBundleError::parse_field( + &path, + self.reader.offset().saturating_sub(4), + format!("array size {size} exceeds limit {MAX_COLLECTION_ITEMS}"), + )); + } + + let data_index = children + .iter() + .copied() + .find(|index| nodes[*index].name == "data") + .or_else(|| children.last().copied()) + .ok_or_else(|| { + AssetBundleError::parse_field( + &path, + self.reader.offset(), + "array/map node has no data child", + ) + })?; + let mut values = Vec::with_capacity(size); + for index in 0..size { + let item_path = format!("{path}[{index}]"); + let mut field = self.decode_node(nodes, data_index, item_path)?; + field.name = collection_item_name(&nodes[array_index], index); + values.push(field); + } + Ok(if is_map { + UnitySerializedValue::Map(values) + } else { + UnitySerializedValue::Array(values) + }) + } + + fn decode_map( + &mut self, + nodes: &[UnityTypeTreeNode], + children: &[usize], + path: String, + ) -> Result { + let array_index = children + .iter() + .copied() + .find(|index| is_array_node(&nodes[*index]) || nodes[*index].name == "Array"); + if let Some(array_index) = array_index { + let array_children = collection_array_children(nodes, array_index); + return self.decode_array(nodes, array_index, &array_children, path, true); + } + let fallback_index = children.first().copied().unwrap_or(0); + self.decode_array(nodes, fallback_index, children, path, true) + } + + fn decode_pptr(&mut self) -> Result { + let file_id = self.reader.read_i32("PPtr.file_id")?; + let path_id = if self.serialized_version >= 14 { + self.reader.read_i64("PPtr.path_id")? + } else { + i64::from(self.reader.read_i32("PPtr.path_id")?) + }; + Ok(UnitySerializedValue::PPtr { file_id, path_id }) + } + + fn decode_enum( + &mut self, + nodes: &[UnityTypeTreeNode], + value_child_index: usize, + path: String, + node: &UnityTypeTreeNode, + ) -> Result { + let child = &nodes[value_child_index]; + let value_field = + self.decode_node(nodes, value_child_index, child_path(&path, &child.name))?; + let value = match value_field.value { + UnitySerializedValue::Signed(value) => value, + UnitySerializedValue::Unsigned(value) => i64::try_from(value).map_err(|_| { + AssetBundleError::parse_field( + &value_field.path, + value_field.offset, + format!("enum value {value} exceeds i64 range"), + ) + })?, + value => { + return Err(AssetBundleError::parse_field( + &value_field.path, + value_field.offset, + format!("enum backing field decoded as non-integer value {value:?}"), + )); + } + }; + Ok(UnitySerializedValue::Enum { + type_name: node.type_name.clone(), + storage_type: value_field.type_name, + value, + }) + } + + fn decode_bitfield( + &mut self, + nodes: &[UnityTypeTreeNode], + bits_child_index: usize, + path: String, + node: &UnityTypeTreeNode, + ) -> Result { + let child = &nodes[bits_child_index]; + let bits_field = + self.decode_node(nodes, bits_child_index, child_path(&path, &child.name))?; + let bits = match bits_field.value { + UnitySerializedValue::Signed(value) => value, + UnitySerializedValue::Unsigned(value) => i64::try_from(value).map_err(|_| { + AssetBundleError::parse_field( + &bits_field.path, + bits_field.offset, + format!("bit field value {value} exceeds i64 range"), + ) + })?, + value => { + return Err(AssetBundleError::parse_field( + &bits_field.path, + bits_field.offset, + format!("bit field backing field decoded as non-integer value {value:?}"), + )); + } + }; + Ok(UnitySerializedValue::BitField { + type_name: node.type_name.clone(), + storage_type: bits_field.type_name, + bits, + }) + } + + fn decode_managed_reference( + &mut self, + nodes: &[UnityTypeTreeNode], + children: &[usize], + path: String, + node: &UnityTypeTreeNode, + ) -> Result { + if !children.is_empty() { + let mut fields = Vec::with_capacity(children.len()); + for child_index in children { + let child = &nodes[*child_index]; + let child_path = child_path(&path, &child.name); + fields.push(self.decode_node(nodes, *child_index, child_path)?); + } + return Ok(UnitySerializedValue::ManagedReference { + type_name: node.type_name.clone(), + metadata: managed_reference_metadata_from_fields(&fields), + fields, + bytes: Vec::new(), + }); + } + if node.byte_size >= 0 { + let size = usize::try_from(node.byte_size).map_err(|_| { + AssetBundleError::parse_field( + &path, + self.reader.offset(), + "managed reference byte_size does not fit usize", + ) + })?; + return Ok(UnitySerializedValue::ManagedReference { + type_name: node.type_name.clone(), + metadata: None, + fields: Vec::new(), + bytes: self.reader.read_bytes(size, &path)?.to_vec(), + }); + } + Err(AssetBundleError::parse_field( + &path, + self.reader.offset(), + format!( + "managed reference TypeTree node {}.{} has no decodable children or fixed byte_size", + node.type_name, node.name + ), + )) + } + + fn decode_managed_reference_registry( + &mut self, + nodes: &[UnityTypeTreeNode], + children: &[usize], + path: String, + ) -> Result { + let mut fields = Vec::with_capacity(children.len()); + for child_index in children { + let child = &nodes[*child_index]; + let child_path = child_path(&path, &child.name); + fields.push(self.decode_node(nodes, *child_index, child_path)?); + } + Ok(UnitySerializedValue::ManagedReferenceRegistry { + references: managed_reference_records_from_fields(&fields), + fields, + }) + } +} + +const TYPE_TREE_ALIGN_BYTES: i32 = 0x4000; +const MAX_COLLECTION_ITEMS: usize = 1_000_000; + +fn node_end(nodes: &[UnityTypeTreeNode], index: usize) -> usize { + let level = nodes[index].level; + nodes + .iter() + .enumerate() + .skip(index + 1) + .find(|(_, node)| node.level <= level) + .map(|(index, _)| index) + .unwrap_or(nodes.len()) +} + +fn direct_children(nodes: &[UnityTypeTreeNode], index: usize, end: usize) -> Vec { + let level = nodes[index].level.saturating_add(1); + (index + 1..end) + .filter(|child_index| nodes[*child_index].level == level) + .collect() +} + +fn is_array_node(node: &UnityTypeTreeNode) -> bool { + node.type_name == "Array" || is_collection_container_type(&node.type_name) || node.is_array +} + +fn is_vector_container_node(node: &UnityTypeTreeNode) -> bool { + is_collection_container_type(&node.type_name) +} + +fn is_collection_container_type(type_name: &str) -> bool { + let key = normalized_metadata_key(type_name); + let lower = type_name.trim().to_ascii_lowercase(); + key == "vector" + || key == "staticvector" + || key == "list" + || key == "hashset" + || lower.starts_with("list<") + || lower.starts_with("hashset<") + || lower.starts_with("set<") + || lower.starts_with("system.collections.generic.list") + || lower.starts_with("system.collections.generic.hashset") +} + +fn unity_array_child_index(nodes: &[UnityTypeTreeNode], children: &[usize]) -> Option { + children + .iter() + .copied() + .find(|index| nodes[*index].type_name == "Array" || nodes[*index].name == "Array") +} + +fn collection_array_children(nodes: &[UnityTypeTreeNode], index: usize) -> Vec { + let end = node_end(nodes, index); + let children = direct_children(nodes, index, end); + if let Some(array_index) = unity_array_child_index(nodes, &children) { + let array_end = node_end(nodes, array_index); + direct_children(nodes, array_index, array_end) + } else { + children + } +} + +fn unity_float32_struct_component_count(type_name: &str) -> Option { + match normalized_metadata_key(type_name).as_str() { + "vector2" | "vector2f" => Some(2), + "vector3" | "vector3f" => Some(3), + "vector4" | "vector4f" | "quaternion" | "quaternionf" | "colorrgba" | "color" | "rect" + | "rectf" => Some(4), + "aabb" | "bounds" | "ray" | "rayf" => Some(6), + "matrix4x4" | "matrix4x4f" => Some(16), + _ => None, + } +} + +fn unity_int32_struct_component_count(type_name: &str) -> Option { + match normalized_metadata_key(type_name).as_str() { + "rangeint" | "vector2int" => Some(2), + "vector3int" => Some(3), + "rectint" => Some(4), + "boundsint" => Some(6), + _ => None, + } +} + +fn unity_fixed_bytes_size(type_name: &str) -> Option { + match normalized_metadata_key(type_name).as_str() { + "guid" | "hash128" => Some(16), + _ => None, + } +} + +fn enum_value_child_index(nodes: &[UnityTypeTreeNode], children: &[usize]) -> Option { + if children.len() != 1 { + return None; + } + let child_index = children[0]; + let child = &nodes[child_index]; + let name_key = normalized_metadata_key(&child.name); + (name_key == "value" && enum_integer_storage_kind(&child.type_name).is_some()) + .then_some(child_index) +} + +fn bitfield_bits_child_index( + nodes: &[UnityTypeTreeNode], + children: &[usize], + node: &UnityTypeTreeNode, +) -> Option { + if !is_bitfield_container_type(&node.type_name) || children.len() != 1 { + return None; + } + let child_index = children[0]; + let child = &nodes[child_index]; + let name_key = normalized_metadata_key(&child.name); + (name_key == "bits" && enum_integer_storage_kind(&child.type_name).is_some()) + .then_some(child_index) +} + +fn is_bitfield_container_type(type_name: &str) -> bool { + matches!( + normalized_metadata_key(type_name).as_str(), + "layermask" | "bitfield" + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnumIntegerStorageKind { + Signed, + Unsigned, +} + +fn enum_integer_storage_kind(type_name: &str) -> Option { + match type_name { + "char" | "SInt8" | "short" | "SInt16" | "int" | "SInt32" | "long long" | "SInt64" => { + Some(EnumIntegerStorageKind::Signed) + } + "UInt8" | "byte" | "UInt16" | "unsigned short" | "UInt32" | "unsigned int" | "UInt64" + | "unsigned long long" => Some(EnumIntegerStorageKind::Unsigned), + _ => None, + } +} + +fn validate_fixed_leaf_byte_size( + path: &str, + offset: usize, + node: &UnityTypeTreeNode, + expected_size: usize, +) -> Result<()> { + if node.byte_size < 0 { + return Ok(()); + } + let actual_size = usize::try_from(node.byte_size).map_err(|_| { + AssetBundleError::parse_field(path, offset, "TypeTree byte_size does not fit usize") + })?; + if actual_size != expected_size { + return Err(AssetBundleError::parse_field( + path, + offset, + format!( + "TypeTree node {}.{} byte_size {} does not match expected fixed leaf size {}", + node.type_name, node.name, actual_size, expected_size + ), + )); + } + Ok(()) +} + +fn is_managed_reference_registry_node(node: &UnityTypeTreeNode) -> bool { + let type_key = normalized_metadata_key(&node.type_name); + let name_key = normalized_metadata_key(&node.name); + matches!( + type_key.as_str(), + "managedreferencesregistry" + | "managedreferenceregistry" + | "serializedreferences" + | "serializedreferenceregistry" + | "serializedreferencesregistry" + ) || matches!( + name_key.as_str(), + "managedreferencesregistry" + | "managedreferenceregistry" + | "managedreferences" + | "serializedreferences" + | "serializedreferenceregistry" + | "serializedreferencesregistry" + ) +} + +fn is_managed_reference_node(node: &UnityTypeTreeNode) -> bool { + let type_key = normalized_metadata_key(&node.type_name); + let name_key = normalized_metadata_key(&node.name); + matches!( + type_key.as_str(), + "managedreference" + | "managedreferenceentry" + | "managedreferencedata" + | "serializedreference" + | "serializedreferenceentry" + | "serializedreferencedata" + | "referencedata" + ) || matches!( + name_key.as_str(), + "managedreference" + | "managedreferenceentry" + | "managedreferencedata" + | "serializedreference" + | "serializedreferenceentry" + | "serializedreferencedata" + | "referencedata" + ) +} + +fn managed_reference_records_from_fields( + fields: &[UnitySerializedField], +) -> Vec { + let mut records = Vec::new(); + collect_managed_reference_records(fields, &mut records); + records +} + +fn collect_managed_reference_records( + fields: &[UnitySerializedField], + records: &mut Vec, +) { + for field in fields { + match &field.value { + UnitySerializedValue::Array(items) | UnitySerializedValue::Map(items) => { + for item in items { + if let Some(record) = managed_reference_record_from_field(item) { + records.push(record); + } else if let Some(children) = serialized_field_children(item) { + collect_managed_reference_records(children, records); + } + } + } + UnitySerializedValue::Object(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } => { + collect_managed_reference_records(children, records); + } + _ => {} + } + } +} + +fn managed_reference_record_from_field( + field: &UnitySerializedField, +) -> Option { + let children = serialized_field_children(field)?; + let metadata = managed_reference_metadata_from_fields(children)?; + let fields = managed_reference_payload_fields(children); + Some(UnityManagedReferenceRecord { metadata, fields }) +} + +pub(crate) fn managed_reference_metadata_from_fields( + fields: &[UnitySerializedField], +) -> Option { + let mut metadata = UnityManagedReferenceMetadata { + reference_id: None, + full_type_name: None, + type_name: None, + namespace: None, + assembly_name: None, + }; + collect_managed_reference_metadata(fields, &mut metadata); + (!metadata.is_empty()).then_some(metadata) +} + +fn collect_managed_reference_metadata( + fields: &[UnitySerializedField], + metadata: &mut UnityManagedReferenceMetadata, +) { + for field in fields { + let key = normalized_metadata_key(&field.name); + match &field.value { + UnitySerializedValue::Signed(value) if is_reference_id_key(&key) => { + metadata.reference_id.get_or_insert(*value); + } + UnitySerializedValue::Unsigned(value) if is_reference_id_key(&key) => { + if let Ok(value) = i64::try_from(*value) { + metadata.reference_id.get_or_insert(value); + } + } + UnitySerializedValue::String(value) if is_type_name_key(&key) => { + collect_managed_reference_type_name(&key, value, metadata); + } + UnitySerializedValue::String(value) if is_namespace_key(&key) => { + if !value.is_empty() { + metadata.namespace.get_or_insert_with(|| value.clone()); + } + } + UnitySerializedValue::String(value) if is_assembly_key(&key) => { + if !value.is_empty() { + metadata.assembly_name.get_or_insert_with(|| value.clone()); + } + } + UnitySerializedValue::Object(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } if !is_payload_data_key(&key) => { + collect_managed_reference_metadata(children, metadata); + } + UnitySerializedValue::Array(items) | UnitySerializedValue::Map(items) + if !is_payload_data_key(&key) => + { + collect_managed_reference_metadata(items, metadata); + } + _ => {} + } + } +} + +fn collect_managed_reference_type_name( + key: &str, + value: &str, + metadata: &mut UnityManagedReferenceMetadata, +) { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + let parsed = parse_managed_reference_type_name(trimmed); + if is_full_type_name_key(key) + || parsed.namespace.as_ref().is_some() + || parsed.assembly_name.as_ref().is_some() + { + metadata + .full_type_name + .get_or_insert_with(|| trimmed.to_string()); + } + if let Some(type_name) = parsed.type_name { + metadata.type_name.get_or_insert(type_name); + } + if let Some(namespace) = parsed.namespace { + metadata.namespace.get_or_insert(namespace); + } + if let Some(assembly_name) = parsed.assembly_name { + metadata.assembly_name.get_or_insert(assembly_name); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedManagedReferenceTypeName { + type_name: Option, + namespace: Option, + assembly_name: Option, +} + +fn parse_managed_reference_type_name(value: &str) -> ParsedManagedReferenceTypeName { + let mut type_part = value.trim(); + let mut assembly_name = None; + + if let Some((type_name, assembly)) = type_part.split_once(',') { + type_part = type_name.trim(); + let assembly = assembly + .split(',') + .next() + .map(str::trim) + .filter(|assembly| !assembly.is_empty()); + if let Some(assembly) = assembly { + assembly_name = Some(assembly.to_string()); + } + } else if let Some((assembly, type_name)) = type_part.split_once("::") { + let assembly = assembly.trim(); + let type_name = type_name.trim(); + if !assembly.is_empty() && !type_name.is_empty() { + assembly_name = Some(assembly.to_string()); + type_part = type_name; + } + } else if let Some(space_index) = type_part.find(char::is_whitespace) { + let (assembly, type_name) = type_part.split_at(space_index); + let assembly = assembly.trim(); + let type_name = type_name.trim(); + if !assembly.is_empty() && !type_name.is_empty() { + assembly_name = Some(assembly.to_string()); + type_part = type_name; + } + } + + let (namespace, type_name) = split_namespace_and_type_name(type_part); + ParsedManagedReferenceTypeName { + type_name, + namespace, + assembly_name, + } +} + +fn split_namespace_and_type_name(value: &str) -> (Option, Option) { + let value = value.trim(); + if value.is_empty() { + return (None, None); + } + if let Some((namespace, type_name)) = value.rsplit_once('.') { + let namespace = (!namespace.is_empty()).then(|| namespace.to_string()); + let type_name = (!type_name.is_empty()).then(|| type_name.to_string()); + (namespace, type_name) + } else { + (None, Some(value.to_string())) + } +} + +fn managed_reference_payload_fields(fields: &[UnitySerializedField]) -> Vec { + for field in fields { + if is_payload_data_key(&normalized_metadata_key(&field.name)) { + if let Some(children) = serialized_field_children(field) { + return children.to_vec(); + } + } + } + fields + .iter() + .filter(|field| !is_managed_reference_metadata_field(field)) + .cloned() + .collect() +} + +fn serialized_field_children(field: &UnitySerializedField) -> Option<&[UnitySerializedField]> { + match &field.value { + UnitySerializedValue::Object(children) + | UnitySerializedValue::Array(children) + | UnitySerializedValue::Map(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } => Some(children), + _ => None, + } +} + +fn is_managed_reference_metadata_field(field: &UnitySerializedField) -> bool { + let key = normalized_metadata_key(&field.name); + is_reference_id_key(&key) + || is_type_name_key(&key) + || is_namespace_key(&key) + || is_assembly_key(&key) +} + +fn normalized_metadata_key(name: &str) -> String { + name.strip_prefix("m_") + .unwrap_or(name) + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn is_reference_id_key(key: &str) -> bool { + matches!( + key, + "rid" + | "id" + | "identifier" + | "refid" + | "refids" + | "referenceid" + | "managedreferenceid" + | "managedreferenceids" + | "managedreferencesid" + | "managedreferencesids" + | "serializedreferenceid" + | "serializedreferenceids" + ) +} + +fn is_type_name_key(key: &str) -> bool { + matches!( + key, + "type" + | "typeid" + | "typeinfo" + | "typename" + | "fullname" + | "fulltypename" + | "class" + | "classname" + | "managedreferenceclassname" + | "serializedreferenceclassname" + | "klass" + | "managedtype" + | "managedreferencetype" + | "managedreferencefullname" + | "managedreferencefulltypename" + | "serializedreferencetype" + | "serializedreferencefullname" + | "serializedreferencefulltypename" + | "assemblyqualifiedname" + ) +} + +fn is_full_type_name_key(key: &str) -> bool { + matches!( + key, + "managedreferencefullname" + | "managedreferencefulltypename" + | "assemblyqualifiedname" + | "managedtype" + | "managedreferencetype" + | "serializedreferencetype" + | "serializedreferencefullname" + | "serializedreferencefulltypename" + | "typename" + | "fullname" + | "fulltypename" + | "type" + | "typeid" + | "typeinfo" + ) +} + +fn is_namespace_key(key: &str) -> bool { + matches!( + key, + "ns" | "namespace" + | "namespacename" + | "managedreferencenamespace" + | "managedreferencenamespacename" + | "serializedreferencenamespace" + | "serializedreferencenamespacename" + ) +} + +fn is_assembly_key(key: &str) -> bool { + matches!( + key, + "asm" + | "asmname" + | "assembly" + | "assemblyname" + | "managedreferenceassembly" + | "managedreferenceassemblyname" + | "serializedreferenceassembly" + | "serializedreferenceassemblyname" + ) +} + +fn is_payload_data_key(key: &str) -> bool { + matches!( + key, + "data" + | "payload" + | "value" + | "object" + | "instance" + | "managedreferencepayload" + | "referencepayload" + | "serializedreferencepayload" + | "managedreferencevalue" + | "referencevalue" + | "serializedreferencevalue" + | "managedreferenceobject" + | "referenceobject" + | "serializedreferenceobject" + | "managedreferencedata" + | "referencedata" + | "serializeddata" + | "serializedreferencedata" + ) +} + +fn child_path(parent: &str, name: &str) -> String { + if parent.is_empty() { + name.to_string() + } else if name.is_empty() { + parent.to_string() + } else { + format!("{parent}.{name}") + } +} + +fn collection_item_name(array_node: &UnityTypeTreeNode, index: usize) -> String { + if array_node.name.is_empty() || array_node.name == "Array" { + format!("[{index}]") + } else { + format!("{}[{index}]", array_node.name) + } +} + +fn find_field_by_path<'a>( + fields: &'a [UnitySerializedField], + field_path: &str, +) -> Option<&'a UnitySerializedField> { + for field in fields { + if field.path == field_path { + return Some(field); + } + match &field.value { + UnitySerializedValue::Object(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } => { + if let Some(found) = find_field_by_path(children, field_path) { + return Some(found); + } + } + UnitySerializedValue::Array(values) | UnitySerializedValue::Map(values) => { + for value in values { + if value.path == field_path { + return Some(value); + } + match &value.value { + UnitySerializedValue::Object(children) + | UnitySerializedValue::ManagedReference { + fields: children, .. + } + | UnitySerializedValue::ManagedReferenceRegistry { + fields: children, .. + } => { + if let Some(found) = find_field_by_path(children, field_path) { + return Some(found); + } + } + UnitySerializedValue::Array(children) + | UnitySerializedValue::Map(children) => { + if let Some(found) = find_field_by_path(children, field_path) { + return Some(found); + } + } + _ => {} + } + } + } + _ => {} + } + } + None +} + fn parse_text_asset(path_id: i64, data: &[u8], endian: Endian) -> Result { let mut reader = Reader::new(data); reader.set_endian(endian); @@ -323,6 +2316,1153 @@ fn parse_text_asset(path_id: i64, data: &[u8], endian: Endian) -> Result Result> { + let name = name.as_bytes(); + let name_len = u32::try_from(name.len()) + .map_err(|_| AssetBundleError::Parse("TextAsset name exceeds u32 length".to_string()))?; + let bytes_len = u32::try_from(bytes.len()) + .map_err(|_| AssetBundleError::Parse("TextAsset bytes exceed u32 length".to_string()))?; + let mut output = Vec::with_capacity(name.len() + bytes.len() + 12); + push_u32_endian(&mut output, name_len, endian); + output.extend_from_slice(name); + let remainder = output.len() % 4; + if remainder != 0 { + output.resize(output.len() + 4 - remainder, 0); + } + push_u32_endian(&mut output, bytes_len, endian); + output.extend_from_slice(bytes); + Ok(output) +} + +fn encode_aligned_string(value: &str, endian: Endian) -> Result> { + let bytes = value.as_bytes(); + let len = u32::try_from(bytes.len()) + .map_err(|_| AssetBundleError::Parse("string field exceeds u32 length".to_string()))?; + let mut output = Vec::with_capacity(bytes.len() + 8); + push_u32_endian(&mut output, len, endian); + output.extend_from_slice(bytes); + let remainder = output.len() % 4; + if remainder != 0 { + output.resize(output.len() + 4 - remainder, 0); + } + Ok(output) +} + +fn encode_float32_struct_values( + type_name: &str, + values: &[u32], + endian: Endian, +) -> Result> { + let expected_count = unity_float32_struct_component_count(type_name).ok_or_else(|| { + AssetBundleError::UnsupportedFormat(format!( + "Unity fixed float32 struct type {type_name} is not supported" + )) + })?; + if values.len() != expected_count { + return Err(AssetBundleError::Parse(format!( + "Unity fixed float32 struct {type_name} expects {expected_count} values, got {}", + values.len() + ))); + } + let mut encoded = Vec::with_capacity(values.len() * 4); + for value in values { + push_u32_endian(&mut encoded, *value, endian); + } + Ok(encoded) +} + +fn encode_int32_struct_values(type_name: &str, values: &[i32], endian: Endian) -> Result> { + let expected_count = unity_int32_struct_component_count(type_name).ok_or_else(|| { + AssetBundleError::UnsupportedFormat(format!( + "Unity fixed int32 struct type {type_name} is not supported" + )) + })?; + if values.len() != expected_count { + return Err(AssetBundleError::Parse(format!( + "Unity fixed int32 struct {type_name} expects {expected_count} values, got {}", + values.len() + ))); + } + let mut encoded = Vec::with_capacity(values.len() * 4); + for value in values { + push_i32_endian(&mut encoded, *value, endian); + } + Ok(encoded) +} + +fn encode_fixed_bytes_value(type_name: &str, bytes: &[u8]) -> Result> { + let expected_size = unity_fixed_bytes_size(type_name).ok_or_else(|| { + AssetBundleError::UnsupportedFormat(format!( + "Unity fixed-byte struct type {type_name} is not supported" + )) + })?; + if bytes.len() != expected_size { + return Err(AssetBundleError::Parse(format!( + "Unity fixed-byte struct {type_name} expects {expected_size} bytes, got {}", + bytes.len() + ))); + } + Ok(bytes.to_vec()) +} + +fn encode_enum_integer(storage_type: &str, value: i64, endian: Endian) -> Result> { + match enum_integer_storage_kind(storage_type) { + Some(EnumIntegerStorageKind::Signed) => encode_signed_integer(storage_type, value, endian), + Some(EnumIntegerStorageKind::Unsigned) => { + let value = u64::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!( + "enum storage {storage_type} replacement {value} cannot be negative" + )) + })?; + encode_unsigned_integer(storage_type, value, endian) + } + None => Err(AssetBundleError::UnsupportedFormat(format!( + "enum backing field type {storage_type} is not a supported integer" + ))), + } +} + +fn enum_child_replacement( + storage_type: &str, + value: i64, +) -> Result { + match enum_integer_storage_kind(storage_type) { + Some(EnumIntegerStorageKind::Signed) => Ok(UnitySerializedReplacementValue::Signed(value)), + Some(EnumIntegerStorageKind::Unsigned) => { + let value = u64::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!( + "enum storage {storage_type} replacement {value} cannot be negative" + )) + })?; + Ok(UnitySerializedReplacementValue::Unsigned(value)) + } + None => Err(AssetBundleError::UnsupportedFormat(format!( + "enum backing field type {storage_type} is not a supported integer" + ))), + } +} + +fn encode_replacement_value( + replacement: &UnitySerializedReplacementValue, + current: &UnitySerializedValue, + type_name: &str, + field_byte_size: usize, + node_index: Option, + context: ReplacementEncodingContext<'_>, +) -> Result> { + match replacement { + UnitySerializedReplacementValue::Bool(value) => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::Bool(_)), + "bool", + )?; + pad_fixed_encoded_value(vec![u8::from(*value)], field_byte_size, type_name) + } + UnitySerializedReplacementValue::Signed(value) => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::Signed(_)), + "signed integer", + )?; + let encoded = encode_signed_integer(type_name, *value, context.endian)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::Unsigned(value) => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::Unsigned(_)), + "unsigned integer", + )?; + let encoded = encode_unsigned_integer(type_name, *value, context.endian)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::Float32(value) => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::Float32(_)), + "float32", + )?; + pad_fixed_encoded_value( + encode_u32_value(*value, context.endian), + field_byte_size, + type_name, + ) + } + UnitySerializedReplacementValue::Float64(value) => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::Float64(_)), + "float64", + )?; + pad_fixed_encoded_value( + encode_u64_value(*value, context.endian), + field_byte_size, + type_name, + ) + } + UnitySerializedReplacementValue::String(value) => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::String(_)), + "string", + )?; + encode_aligned_string(value, context.endian) + } + UnitySerializedReplacementValue::Bytes(value) => match current { + UnitySerializedValue::Bytes(current_bytes) => { + let value_len = u32::try_from(value.len()).map_err(|_| { + AssetBundleError::Parse(format!("bytes field {type_name} exceeds u32 length")) + })?; + let mut encoded = Vec::with_capacity(value.len() + 8); + push_u32_endian(&mut encoded, value_len, context.endian); + encoded.extend_from_slice(value); + if field_byte_size > 4usize.saturating_add(current_bytes.len()) { + align_vec_to(&mut encoded, 4); + } + Ok(encoded) + } + UnitySerializedValue::Unknown { + type_name: unknown_type, + bytes: current_bytes, + } => { + if value.len() != current_bytes.len() { + return Err(AssetBundleError::Parse(format!( + "unknown fixed field {unknown_type} replacement length {} must match current byte length {}", + value.len(), + current_bytes.len() + ))); + } + pad_fixed_encoded_value(value.clone(), field_byte_size, unknown_type) + } + _ => Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not TypelessData/bytes or fixed-size unknown bytes" + ))), + }, + UnitySerializedReplacementValue::Float32Struct { + type_name: replacement_type, + values, + } => { + let Some(current_type) = current_float32_struct_type_name(current, type_name) else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not a fixed float32 Unity struct" + ))); + }; + if normalized_metadata_key(replacement_type) != normalized_metadata_key(current_type) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement type {} does not match current fixed float32 struct {}", + replacement_type, current_type + ))); + } + let encoded = encode_float32_struct_values(current_type, values, context.endian)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::Int32Struct { + type_name: replacement_type, + values, + } => { + let Some(current_type) = current_int32_struct_type_name(current, type_name) else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not a fixed int32 Unity struct" + ))); + }; + if normalized_metadata_key(replacement_type) != normalized_metadata_key(current_type) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement type {} does not match current fixed int32 struct {}", + replacement_type, current_type + ))); + } + let encoded = encode_int32_struct_values(current_type, values, context.endian)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::FixedBytes { + type_name: replacement_type, + bytes, + } => { + let Some(current_type) = current_fixed_bytes_type_name(current, type_name) else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not a fixed-byte Unity struct" + ))); + }; + if normalized_metadata_key(replacement_type) != normalized_metadata_key(current_type) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement type {} does not match current fixed-byte struct {}", + replacement_type, current_type + ))); + } + let encoded = encode_fixed_bytes_value(current_type, bytes)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::Enum { + type_name: replacement_type, + storage_type, + value, + } => { + let UnitySerializedValue::Enum { + type_name: current_type, + storage_type: current_storage, + .. + } = current + else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not an enum" + ))); + }; + if normalized_metadata_key(replacement_type) != normalized_metadata_key(current_type) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement enum type {} does not match current enum {}", + replacement_type, current_type + ))); + } + if normalized_metadata_key(storage_type) != normalized_metadata_key(current_storage) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement enum storage {} does not match current enum storage {}", + storage_type, current_storage + ))); + } + let encoded = encode_enum_integer(current_storage, *value, context.endian)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::BitField { + type_name: replacement_type, + storage_type, + bits, + } => { + let UnitySerializedValue::BitField { + type_name: current_type, + storage_type: current_storage, + .. + } = current + else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not a bit field" + ))); + }; + if normalized_metadata_key(replacement_type) != normalized_metadata_key(current_type) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement bit field type {} does not match current bit field {}", + replacement_type, current_type + ))); + } + if normalized_metadata_key(storage_type) != normalized_metadata_key(current_storage) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement bit field storage {} does not match current bit field storage {}", + storage_type, current_storage + ))); + } + let encoded = encode_enum_integer(current_storage, *bits, context.endian)?; + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::PPtr { file_id, path_id } => { + require_current_kind( + current, + matches!(current, UnitySerializedValue::PPtr { .. }), + "PPtr", + )?; + let mut encoded = Vec::with_capacity(16); + push_i32_endian(&mut encoded, *file_id, context.endian); + if context.serialized_version >= 14 { + push_i64_endian(&mut encoded, *path_id, context.endian); + } else { + let path_id = i32::try_from(*path_id).map_err(|_| { + AssetBundleError::Parse(format!( + "PPtr path_id {} exceeds legacy i32 range", + path_id + )) + })?; + push_i32_endian(&mut encoded, path_id, context.endian); + } + pad_fixed_encoded_value(encoded, field_byte_size, type_name) + } + UnitySerializedReplacementValue::Array(items) => { + encode_collection_replacement(items, current, "array", node_index, context) + } + UnitySerializedReplacementValue::Map(items) => { + encode_collection_replacement(items, current, "map", node_index, context) + } + UnitySerializedReplacementValue::Object(fields) => { + encode_object_replacement(fields, current, context) + } + } +} + +#[derive(Clone, Copy)] +struct ReplacementEncodingContext<'a> { + serialized_version: u32, + endian: Endian, + nodes: Option<&'a [UnityTypeTreeNode]>, +} + +fn current_float32_struct_type_name<'a>( + current: &'a UnitySerializedValue, + field_type_name: &'a str, +) -> Option<&'a str> { + match current { + UnitySerializedValue::Float32Struct { type_name, .. } => Some(type_name), + UnitySerializedValue::Object(_) + if object_float32_struct_values(current, field_type_name).is_some() => + { + Some(field_type_name) + } + _ => None, + } +} + +fn current_int32_struct_type_name<'a>( + current: &'a UnitySerializedValue, + field_type_name: &'a str, +) -> Option<&'a str> { + match current { + UnitySerializedValue::Int32Struct { type_name, .. } => Some(type_name), + UnitySerializedValue::Object(_) + if object_int32_struct_values(current, field_type_name).is_some() => + { + Some(field_type_name) + } + _ => None, + } +} + +fn current_fixed_bytes_type_name<'a>( + current: &'a UnitySerializedValue, + field_type_name: &'a str, +) -> Option<&'a str> { + match current { + UnitySerializedValue::FixedBytes { type_name, .. } => Some(type_name), + UnitySerializedValue::Object(_) + if object_fixed_bytes_value(current, field_type_name).is_some() => + { + Some(field_type_name) + } + _ => None, + } +} + +fn encode_collection_replacement( + replacements: &[UnitySerializedReplacementValue], + current: &UnitySerializedValue, + collection_kind: &str, + node_index: Option, + context: ReplacementEncodingContext<'_>, +) -> Result> { + let current_items = match (collection_kind, current) { + ("array", UnitySerializedValue::Array(items)) + | ("map", UnitySerializedValue::Map(items)) => items, + _ => { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field value {:?} is not {collection_kind}", + current + ))); + } + }; + let len = i32::try_from(replacements.len()).map_err(|_| { + AssetBundleError::Parse(format!( + "{collection_kind} replacement length {} exceeds i32 range", + replacements.len() + )) + })?; + if replacements.len() > MAX_COLLECTION_ITEMS { + return Err(AssetBundleError::Parse(format!( + "{collection_kind} replacement length {} exceeds limit {MAX_COLLECTION_ITEMS}", + replacements.len() + ))); + } + if replacements.is_empty() { + let mut output = Vec::with_capacity(4); + push_i32_endian(&mut output, len, context.endian); + return Ok(output); + } + let mut output = Vec::with_capacity( + 4usize.saturating_add( + current_items + .first() + .map(|template| template.byte_size) + .unwrap_or(0) + .saturating_mul(replacements.len()), + ), + ); + push_i32_endian(&mut output, len, context.endian); + if let Some(template) = current_items.first() { + for (index, replacement) in replacements.iter().enumerate() { + let current_item = current_items.get(index).unwrap_or(template); + output.extend_from_slice(&encode_replacement_value( + replacement, + ¤t_item.value, + ¤t_item.type_name, + current_item.byte_size, + current_item.type_tree_node_index, + context, + )?); + } + } else { + let nodes = context.nodes.ok_or_else(|| { + AssetBundleError::UnsupportedFormat(format!( + "cannot encode non-empty replacement for an empty TypeTree {collection_kind} without TypeTree nodes" + )) + })?; + let node_index = node_index.ok_or_else(|| { + AssetBundleError::UnsupportedFormat(format!( + "cannot encode non-empty replacement for an empty TypeTree {collection_kind} without TypeTree node index" + )) + })?; + let data_index = collection_data_node_index(nodes, node_index, collection_kind)?; + for replacement in replacements { + output.extend_from_slice(&encode_replacement_node( + replacement, + nodes, + data_index, + context.serialized_version, + context.endian, + )?); + } + } + Ok(output) +} + +fn encode_replacement_node( + replacement: &UnitySerializedReplacementValue, + nodes: &[UnityTypeTreeNode], + index: usize, + serialized_version: u32, + endian: Endian, +) -> Result> { + let node = nodes.get(index).ok_or_else(|| { + AssetBundleError::Parse(format!("TypeTree node index {index} is out of range")) + })?; + let mut encoded = + encode_replacement_node_value(replacement, nodes, index, node, serialized_version, endian)?; + if node.meta_flag & TYPE_TREE_ALIGN_BYTES != 0 { + align_vec_to(&mut encoded, 4); + } + Ok(encoded) +} + +fn encode_replacement_node_value( + replacement: &UnitySerializedReplacementValue, + nodes: &[UnityTypeTreeNode], + index: usize, + node: &UnityTypeTreeNode, + serialized_version: u32, + endian: Endian, +) -> Result> { + if node.type_name == "map" { + let UnitySerializedReplacementValue::Map(items) = replacement else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement {:?} is not map for TypeTree node {}", + replacement, node.name + ))); + }; + return encode_collection_replacement_from_schema( + items, + nodes, + collection_data_node_index(nodes, index, "map")?, + serialized_version, + endian, + "map", + ); + } + if is_array_node(node) { + let UnitySerializedReplacementValue::Array(items) = replacement else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement {:?} is not array for TypeTree node {}", + replacement, node.name + ))); + }; + return encode_collection_replacement_from_schema( + items, + nodes, + collection_data_node_index(nodes, index, "array")?, + serialized_version, + endian, + "array", + ); + } + let end = node_end(nodes, index); + let children = direct_children(nodes, index, end); + if let Some(bits_child_index) = bitfield_bits_child_index(nodes, &children, node) { + let UnitySerializedReplacementValue::BitField { + type_name, + storage_type, + bits, + } = replacement + else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement {:?} is not bit field for TypeTree node {}.{}", + replacement, node.type_name, node.name + ))); + }; + if normalized_metadata_key(type_name) != normalized_metadata_key(&node.type_name) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement bit field type {} does not match TypeTree bit field {}", + type_name, node.type_name + ))); + } + let child = &nodes[bits_child_index]; + if normalized_metadata_key(storage_type) != normalized_metadata_key(&child.type_name) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement bit field storage {} does not match TypeTree bit field storage {}", + storage_type, child.type_name + ))); + } + let encoded = encode_replacement_node( + &enum_child_replacement(&child.type_name, *bits)?, + nodes, + bits_child_index, + serialized_version, + endian, + )?; + return pad_schema_encoded_value(encoded, node); + } + if let Some(value_child_index) = enum_value_child_index(nodes, &children) { + let UnitySerializedReplacementValue::Enum { + type_name, + storage_type, + value, + } = replacement + else { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement {:?} is not enum for TypeTree node {}.{}", + replacement, node.type_name, node.name + ))); + }; + if normalized_metadata_key(type_name) != normalized_metadata_key(&node.type_name) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement enum type {} does not match TypeTree enum {}", + type_name, node.type_name + ))); + } + let child = &nodes[value_child_index]; + if normalized_metadata_key(storage_type) != normalized_metadata_key(&child.type_name) { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement enum storage {} does not match TypeTree enum storage {}", + storage_type, child.type_name + ))); + } + let encoded = encode_replacement_node( + &enum_child_replacement(&child.type_name, *value)?, + nodes, + value_child_index, + serialized_version, + endian, + )?; + return pad_schema_encoded_value(encoded, node); + } + + let encoded = match replacement { + UnitySerializedReplacementValue::Bool(value) if node.type_name == "bool" => { + vec![u8::from(*value)] + } + UnitySerializedReplacementValue::Signed(value) => { + encode_signed_integer(&node.type_name, *value, endian)? + } + UnitySerializedReplacementValue::Unsigned(value) => { + encode_unsigned_integer(&node.type_name, *value, endian)? + } + UnitySerializedReplacementValue::Float32(value) if node.type_name == "float" => { + encode_u32_value(*value, endian) + } + UnitySerializedReplacementValue::Float64(value) if node.type_name == "double" => { + encode_u64_value(*value, endian) + } + UnitySerializedReplacementValue::String(value) if node.type_name == "string" => { + encode_aligned_string(value, endian)? + } + UnitySerializedReplacementValue::Bytes(value) + if matches!(node.type_name.as_str(), "TypelessData" | "bytes") => + { + let value_len = u32::try_from(value.len()).map_err(|_| { + AssetBundleError::Parse(format!("bytes field {} exceeds u32 length", node.name)) + })?; + let mut encoded = Vec::with_capacity(value.len() + 4); + push_u32_endian(&mut encoded, value_len, endian); + encoded.extend_from_slice(value); + encoded + } + UnitySerializedReplacementValue::Bytes(value) + if node.byte_size >= 0 + && direct_children(nodes, index, node_end(nodes, index)).is_empty() => + { + let field_byte_size = usize::try_from(node.byte_size).map_err(|_| { + AssetBundleError::Parse(format!( + "TypeTree node {} byte_size does not fit usize", + node.name + )) + })?; + if value.len() != field_byte_size { + return Err(AssetBundleError::Parse(format!( + "unknown fixed field {} replacement length {} must match TypeTree byte_size {}", + node.name, + value.len(), + field_byte_size + ))); + } + value.clone() + } + UnitySerializedReplacementValue::Float32Struct { type_name, values } + if normalized_metadata_key(type_name) == normalized_metadata_key(&node.type_name) => + { + encode_float32_struct_values(&node.type_name, values, endian)? + } + UnitySerializedReplacementValue::Int32Struct { type_name, values } + if normalized_metadata_key(type_name) == normalized_metadata_key(&node.type_name) => + { + encode_int32_struct_values(&node.type_name, values, endian)? + } + UnitySerializedReplacementValue::FixedBytes { type_name, bytes } + if normalized_metadata_key(type_name) == normalized_metadata_key(&node.type_name) => + { + encode_fixed_bytes_value(&node.type_name, bytes)? + } + UnitySerializedReplacementValue::PPtr { file_id, path_id } + if node.type_name.starts_with("PPtr<") || node.type_name == "PPtr" => + { + let mut encoded = Vec::with_capacity(16); + push_i32_endian(&mut encoded, *file_id, endian); + if serialized_version >= 14 { + push_i64_endian(&mut encoded, *path_id, endian); + } else { + let path_id = i32::try_from(*path_id).map_err(|_| { + AssetBundleError::Parse(format!( + "PPtr path_id {} exceeds legacy i32 range", + path_id + )) + })?; + push_i32_endian(&mut encoded, path_id, endian); + } + encoded + } + UnitySerializedReplacementValue::Object(fields) => { + encode_object_replacement_from_schema(fields, nodes, index, serialized_version, endian)? + } + _ => { + return Err(AssetBundleError::UnsupportedFormat(format!( + "replacement {:?} does not match TypeTree node {}.{}", + replacement, node.type_name, node.name + ))); + } + }; + pad_schema_encoded_value(encoded, node) +} + +fn encode_collection_replacement_from_schema( + replacements: &[UnitySerializedReplacementValue], + nodes: &[UnityTypeTreeNode], + data_index: usize, + serialized_version: u32, + endian: Endian, + collection_kind: &str, +) -> Result> { + let len = i32::try_from(replacements.len()).map_err(|_| { + AssetBundleError::Parse(format!( + "{collection_kind} replacement length {} exceeds i32 range", + replacements.len() + )) + })?; + if replacements.len() > MAX_COLLECTION_ITEMS { + return Err(AssetBundleError::Parse(format!( + "{collection_kind} replacement length {} exceeds limit {MAX_COLLECTION_ITEMS}", + replacements.len() + ))); + } + let mut output = Vec::new(); + push_i32_endian(&mut output, len, endian); + for replacement in replacements { + output.extend_from_slice(&encode_replacement_node( + replacement, + nodes, + data_index, + serialized_version, + endian, + )?); + } + Ok(output) +} + +fn encode_object_replacement_from_schema( + replacements: &[UnitySerializedFieldReplacement], + nodes: &[UnityTypeTreeNode], + index: usize, + serialized_version: u32, + endian: Endian, +) -> Result> { + let end = node_end(nodes, index); + let children = direct_children(nodes, index, end); + let mut used = vec![false; replacements.len()]; + let mut output = Vec::new(); + for child_index in children { + let child = &nodes[child_index]; + let Some((replacement_index, replacement)) = replacements + .iter() + .enumerate() + .find(|(index, replacement)| !used[*index] && replacement.name == child.name) + else { + return Err(AssetBundleError::Parse(format!( + "object replacement missing field {}", + child.name + ))); + }; + used[replacement_index] = true; + output.extend_from_slice(&encode_replacement_node( + &replacement.value, + nodes, + child_index, + serialized_version, + endian, + )?); + } + if let Some(extra) = replacements + .iter() + .zip(used.iter()) + .find_map(|(replacement, used)| (!*used).then_some(replacement)) + { + return Err(AssetBundleError::Parse(format!( + "object replacement field {} does not exist in current TypeTree object", + extra.name + ))); + } + Ok(output) +} + +fn collection_data_node_index( + nodes: &[UnityTypeTreeNode], + index: usize, + collection_kind: &str, +) -> Result { + let node = nodes.get(index).ok_or_else(|| { + AssetBundleError::Parse(format!("TypeTree node index {index} is out of range")) + })?; + let end = node_end(nodes, index); + let children = direct_children(nodes, index, end); + if collection_kind == "map" && node.type_name == "map" { + let array_index = children + .iter() + .copied() + .find(|child_index| is_array_node(&nodes[*child_index])) + .or_else(|| children.first().copied()) + .ok_or_else(|| { + AssetBundleError::parse_field( + &node.name, + 0, + "map node has no array child for replacement schema", + ) + })?; + return collection_data_node_index(nodes, array_index, "array"); + } + let array_children = if collection_kind == "array" { + collection_array_children(nodes, index) + } else { + children + }; + array_children + .iter() + .copied() + .find(|child_index| nodes[*child_index].name == "data") + .or_else(|| array_children.last().copied()) + .ok_or_else(|| { + AssetBundleError::parse_field( + &node.name, + 0, + "array/map node has no data child for replacement schema", + ) + }) +} + +fn pad_schema_encoded_value(encoded: Vec, node: &UnityTypeTreeNode) -> Result> { + if node.byte_size < 0 { + return Ok(encoded); + } + let field_byte_size = usize::try_from(node.byte_size).map_err(|_| { + AssetBundleError::Parse(format!( + "TypeTree node {} byte_size does not fit usize", + node.name + )) + })?; + pad_fixed_encoded_value(encoded, field_byte_size, &node.type_name) +} + +fn encode_object_replacement( + replacements: &[UnitySerializedFieldReplacement], + current: &UnitySerializedValue, + context: ReplacementEncodingContext<'_>, +) -> Result> { + let current_fields = match current { + UnitySerializedValue::Object(fields) + | UnitySerializedValue::ManagedReference { fields, .. } + | UnitySerializedValue::ManagedReferenceRegistry { fields, .. } => fields, + _ => { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field value {:?} is not object", + current + ))); + } + }; + + let mut used = vec![false; replacements.len()]; + let mut output = Vec::new(); + for current_field in current_fields { + let Some((replacement_index, replacement)) = replacements + .iter() + .enumerate() + .find(|(index, replacement)| !used[*index] && replacement.name == current_field.name) + else { + return Err(AssetBundleError::Parse(format!( + "object replacement missing field {}", + current_field.name + ))); + }; + used[replacement_index] = true; + output.extend_from_slice(&encode_replacement_value( + &replacement.value, + ¤t_field.value, + ¤t_field.type_name, + current_field.byte_size, + current_field.type_tree_node_index, + context, + )?); + } + + if let Some(extra) = replacements + .iter() + .zip(used.iter()) + .find_map(|(replacement, used)| (!*used).then_some(replacement)) + { + return Err(AssetBundleError::Parse(format!( + "object replacement field {} does not exist in current TypeTree object", + extra.name + ))); + } + + Ok(output) +} + +fn require_current_kind( + current: &UnitySerializedValue, + matches_kind: bool, + expected: &str, +) -> Result<()> { + if matches_kind { + Ok(()) + } else { + Err(AssetBundleError::UnsupportedFormat(format!( + "field value {:?} is not {expected}", + current + ))) + } +} + +fn encode_signed_integer(type_name: &str, value: i64, endian: Endian) -> Result> { + let mut output = Vec::new(); + match type_name { + "char" | "SInt8" => output.push(i8::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!("{type_name} replacement {value} out of range")) + })? as u8), + "short" | "SInt16" => { + push_i16_endian( + &mut output, + i16::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!("{type_name} replacement {value} out of range")) + })?, + endian, + ); + } + "int" | "SInt32" => { + push_i32_endian( + &mut output, + i32::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!("{type_name} replacement {value} out of range")) + })?, + endian, + ); + } + "long long" | "SInt64" => push_i64_endian(&mut output, value, endian), + _ => { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not a supported signed integer" + ))) + } + } + Ok(output) +} + +fn encode_unsigned_integer(type_name: &str, value: u64, endian: Endian) -> Result> { + let mut output = Vec::new(); + match type_name { + "UInt8" | "byte" => output.push(u8::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!("{type_name} replacement {value} out of range")) + })?), + "UInt16" | "unsigned short" => { + push_u16_endian( + &mut output, + u16::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!("{type_name} replacement {value} out of range")) + })?, + endian, + ); + } + "UInt32" | "unsigned int" => { + push_u32_endian( + &mut output, + u32::try_from(value).map_err(|_| { + AssetBundleError::Parse(format!("{type_name} replacement {value} out of range")) + })?, + endian, + ); + } + "UInt64" | "unsigned long long" => push_u64_endian(&mut output, value, endian), + _ => { + return Err(AssetBundleError::UnsupportedFormat(format!( + "field type {type_name} is not a supported unsigned integer" + ))) + } + } + Ok(output) +} + +fn pad_fixed_encoded_value( + mut encoded: Vec, + field_byte_size: usize, + type_name: &str, +) -> Result> { + if encoded.len() > field_byte_size { + return Err(AssetBundleError::Parse(format!( + "encoded {type_name} value is {} bytes but field size is {}", + encoded.len(), + field_byte_size + ))); + } + encoded.resize(field_byte_size, 0); + Ok(encoded) +} + +fn align_vec_to(output: &mut Vec, alignment: usize) { + let remainder = output.len() % alignment; + if remainder != 0 { + output.resize(output.len() + alignment - remainder, 0); + } +} + +fn push_u32_endian(output: &mut Vec, value: u32, endian: Endian) { + match endian { + Endian::Little => output.extend_from_slice(&value.to_le_bytes()), + Endian::Big => output.extend_from_slice(&value.to_be_bytes()), + } +} + +fn push_i16_endian(output: &mut Vec, value: i16, endian: Endian) { + match endian { + Endian::Little => output.extend_from_slice(&value.to_le_bytes()), + Endian::Big => output.extend_from_slice(&value.to_be_bytes()), + } +} + +fn push_u16_endian(output: &mut Vec, value: u16, endian: Endian) { + match endian { + Endian::Little => output.extend_from_slice(&value.to_le_bytes()), + Endian::Big => output.extend_from_slice(&value.to_be_bytes()), + } +} + +fn push_i32_endian(output: &mut Vec, value: i32, endian: Endian) { + match endian { + Endian::Little => output.extend_from_slice(&value.to_le_bytes()), + Endian::Big => output.extend_from_slice(&value.to_be_bytes()), + } +} + +fn push_i64_endian(output: &mut Vec, value: i64, endian: Endian) { + match endian { + Endian::Little => output.extend_from_slice(&value.to_le_bytes()), + Endian::Big => output.extend_from_slice(&value.to_be_bytes()), + } +} + +fn push_u64_endian(output: &mut Vec, value: u64, endian: Endian) { + match endian { + Endian::Little => output.extend_from_slice(&value.to_le_bytes()), + Endian::Big => output.extend_from_slice(&value.to_be_bytes()), + } +} + +fn encode_u32_value(value: u32, endian: Endian) -> Vec { + let mut output = Vec::with_capacity(4); + push_u32_endian(&mut output, value, endian); + output +} + +fn encode_u64_value(value: u64, endian: Endian) -> Vec { + let mut output = Vec::with_capacity(8); + push_u64_endian(&mut output, value, endian); + output +} + +fn write_u32_be(output: &mut [u8], offset: usize, value: u32) -> Result<()> { + let end = offset.checked_add(4).ok_or_else(|| { + AssetBundleError::Parse("serialized header write offset overflow".to_string()) + })?; + let output_len = output.len(); + let bytes = output.get_mut(offset..end).ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized header write range {}..{} exceeds file size {}", + offset, end, output_len + )) + })?; + bytes.copy_from_slice(&value.to_be_bytes()); + Ok(()) +} + +fn write_u64_be(output: &mut [u8], offset: usize, value: u64) -> Result<()> { + let end = offset.checked_add(8).ok_or_else(|| { + AssetBundleError::Parse("serialized header write offset overflow".to_string()) + })?; + let output_len = output.len(); + let bytes = output.get_mut(offset..end).ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized header write range {}..{} exceeds file size {}", + offset, end, output_len + )) + })?; + bytes.copy_from_slice(&value.to_be_bytes()); + Ok(()) +} + +fn write_u32_endian(output: &mut [u8], offset: usize, value: u32, endian: Endian) -> Result<()> { + let end = offset.checked_add(4).ok_or_else(|| { + AssetBundleError::Parse("serialized metadata write offset overflow".to_string()) + })?; + let output_len = output.len(); + let bytes = output.get_mut(offset..end).ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized metadata write range {}..{} exceeds file size {}", + offset, end, output_len + )) + })?; + let encoded = match endian { + Endian::Little => value.to_le_bytes(), + Endian::Big => value.to_be_bytes(), + }; + bytes.copy_from_slice(&encoded); + Ok(()) +} + +fn write_u64_endian(output: &mut [u8], offset: usize, value: u64, endian: Endian) -> Result<()> { + let end = offset.checked_add(8).ok_or_else(|| { + AssetBundleError::Parse("serialized metadata write offset overflow".to_string()) + })?; + let output_len = output.len(); + let bytes = output.get_mut(offset..end).ok_or_else(|| { + AssetBundleError::Parse(format!( + "serialized metadata write range {}..{} exceeds file size {}", + offset, end, output_len + )) + })?; + let encoded = match endian { + Endian::Little => value.to_le_bytes(), + Endian::Big => value.to_be_bytes(), + }; + bytes.copy_from_slice(&encoded); + Ok(()) +} + fn read_serialized_type( reader: &mut Reader<'_>, version: u32, @@ -672,6 +3812,10 @@ impl<'a> Reader<'a> { Ok(self.read_bytes(1, field)?[0]) } + fn read_i8(&mut self, field: &str) -> Result { + Ok(self.read_u8(field)? as i8) + } + fn read_u16(&mut self, field: &str) -> Result { let bytes = self.read_bytes(2, field)?; Ok(match self.endian { @@ -768,6 +3912,12 @@ impl<'a> Reader<'a> { AssetBundleError::parse_field(field, self.offset, format!("invalid UTF-8: {error}")) }) } + + fn read_aligned_string(&mut self, field: &str) -> Result { + let value = self.read_len_prefixed_string(field)?; + self.align(4)?; + Ok(value) + } } #[cfg(test)] @@ -853,6 +4003,1245 @@ mod tests { file } + fn synthetic_serialized_monobehaviour() -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + align(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let field_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let field_name = strings.len(); + strings.extend_from_slice(b"message\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 2); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, field_type as i32, field_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_managed_reference() -> Vec { + synthetic_serialized_managed_reference_with_type(b"managedReference") + } + + fn synthetic_serialized_managed_reference_with_type(managed_type_name: &[u8]) -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + align(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let managed_type = strings.len(); + strings.extend_from_slice(managed_type_name); + strings.push(0); + let managed_name = strings.len(); + strings.extend_from_slice(b"entry\0"); + let field_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let field_name = strings.len(); + strings.extend_from_slice(b"message\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 3); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, managed_type as i32, managed_name as i32), + (2u8, field_type as i32, field_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_managed_reference_registry() -> Vec { + synthetic_serialized_managed_reference_registry_with_names( + b"managedReferencesRegistry", + b"m_SerializedReferences", + b"type", + b"data", + ) + } + + struct ManagedReferenceRegistryNames<'a> { + registry_type_name: &'a [u8], + registry_field_name: &'a [u8], + array_field_name: &'a [u8], + rid_field_name: &'a [u8], + type_field_name: &'a [u8], + class_field_name: &'a [u8], + namespace_field_name: &'a [u8], + assembly_field_name: &'a [u8], + payload_field_name: &'a [u8], + } + + fn synthetic_serialized_managed_reference_registry_with_names( + registry_type_name: &[u8], + registry_field_name: &[u8], + type_field_name: &[u8], + payload_field_name: &[u8], + ) -> Vec { + synthetic_serialized_managed_reference_registry_with_detailed_names( + ManagedReferenceRegistryNames { + registry_type_name, + registry_field_name, + array_field_name: b"references", + rid_field_name: b"rid", + type_field_name, + class_field_name: b"class", + namespace_field_name: b"ns", + assembly_field_name: b"asm", + payload_field_name, + }, + ) + } + + fn synthetic_serialized_managed_reference_registry_with_detailed_names( + names: ManagedReferenceRegistryNames<'_>, + ) -> Vec { + let ManagedReferenceRegistryNames { + registry_type_name, + registry_field_name, + type_field_name, + array_field_name, + rid_field_name, + class_field_name, + namespace_field_name, + assembly_field_name, + payload_field_name, + } = names; + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 1); + push_i64_le(&mut object_data, 42); + for value in ["ScenarioLine", "BA.Text", "Game"] { + push_u32_le(&mut object_data, value.len() as u32); + object_data.extend_from_slice(value.as_bytes()); + align(&mut object_data, 4); + } + push_u32_le(&mut object_data, "こんにちは".len() as u32); + object_data.extend_from_slice("こんにちは".as_bytes()); + align(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let registry_type = strings.len(); + strings.extend_from_slice(registry_type_name); + strings.push(0); + let registry_name = strings.len(); + strings.extend_from_slice(registry_field_name); + strings.push(0); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(array_field_name); + strings.push(0); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let entry_type = strings.len(); + strings.extend_from_slice(b"ManagedReferenceEntry\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + let rid_type = strings.len(); + strings.extend_from_slice(b"long long\0"); + let rid_name = strings.len(); + strings.extend_from_slice(rid_field_name); + strings.push(0); + let type_info_type = strings.len(); + strings.extend_from_slice(b"ManagedReferenceType\0"); + let type_info_name = strings.len(); + strings.extend_from_slice(type_field_name); + strings.push(0); + let string_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let class_name = strings.len(); + strings.extend_from_slice(class_field_name); + strings.push(0); + let namespace_name = strings.len(); + strings.extend_from_slice(namespace_field_name); + strings.push(0); + let assembly_name = strings.len(); + strings.extend_from_slice(assembly_field_name); + strings.push(0); + let managed_type = strings.len(); + strings.extend_from_slice(b"managedReference\0"); + let payload_name = strings.len(); + strings.extend_from_slice(payload_field_name); + strings.push(0); + let message_name = strings.len(); + strings.extend_from_slice(b"message\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 12); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, registry_type as i32, registry_name as i32), + (2u8, array_type as i32, array_name as i32), + (3u8, size_type as i32, size_name as i32), + (3u8, entry_type as i32, data_name as i32), + (4u8, rid_type as i32, rid_name as i32), + (4u8, type_info_type as i32, type_info_name as i32), + (5u8, string_type as i32, class_name as i32), + (5u8, string_type as i32, namespace_name as i32), + (5u8, string_type as i32, assembly_name as i32), + (4u8, managed_type as i32, payload_name as i32), + (5u8, string_type as i32, message_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_string_array() -> Vec { + synthetic_serialized_string_array_with_values(&["hello", "world"]) + } + + fn synthetic_serialized_empty_string_array() -> Vec { + synthetic_serialized_string_array_with_values(&[]) + } + + fn synthetic_serialized_vector_string_array() -> Vec { + synthetic_serialized_vector_string_array_with_values(&["hello", "world"]) + } + + fn synthetic_serialized_empty_vector_string_array() -> Vec { + synthetic_serialized_vector_string_array_with_values(&[]) + } + + fn synthetic_serialized_list_string_array() -> Vec { + synthetic_serialized_collection_string_array_with_values( + b"List", + &["hello", "world"], + ) + } + + fn synthetic_serialized_empty_hashset_string_array() -> Vec { + synthetic_serialized_collection_string_array_with_values(b"HashSet", &[]) + } + + fn synthetic_serialized_vector_string_array_with_values(values: &[&str]) -> Vec { + synthetic_serialized_collection_string_array_with_values(b"vector", values) + } + + fn synthetic_serialized_collection_string_array_with_values( + collection_type_name: &[u8], + values: &[&str], + ) -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, values.len() as i32); + for value in values { + push_u32_le(&mut object_data, value.len() as u32); + object_data.extend_from_slice(value.as_bytes()); + align(&mut object_data, 4); + } + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let vector_type = strings.len(); + strings.extend_from_slice(collection_type_name); + strings.push(0); + let vector_name = strings.len(); + strings.extend_from_slice(b"messages\0"); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"Array\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let data_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 5); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, vector_type as i32, vector_name as i32), + (2u8, array_type as i32, array_name as i32), + (3u8, size_type as i32, size_name as i32), + (3u8, data_type as i32, data_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_string_array_with_values(values: &[&str]) -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, values.len() as i32); + for value in values { + push_u32_le(&mut object_data, value.len() as u32); + object_data.extend_from_slice(value.as_bytes()); + align(&mut object_data, 4); + } + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"messages\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let data_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 4); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, array_type as i32, array_name as i32), + (2u8, size_type as i32, size_name as i32), + (2u8, data_type as i32, data_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_int_array() -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + push_i32_le(&mut object_data, 10); + push_i32_le(&mut object_data, 20); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"scores\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let data_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 4); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, array_type as i32, array_name as i32), + (2u8, size_type as i32, size_name as i32), + (2u8, data_type as i32, data_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_unity_leaf_structs() -> Vec { + let mut object_data = Vec::new(); + for value in [ + 1.0f32.to_bits(), + 0.5f32.to_bits(), + 0.25f32.to_bits(), + 1.0f32.to_bits(), + ] { + push_u32_le(&mut object_data, value); + } + let guid: Vec = (0u8..16).collect(); + object_data.extend_from_slice(&guid); + for value in [1.0f32.to_bits(), 2.0f32.to_bits(), 3.0f32.to_bits()] { + push_u32_le(&mut object_data, value); + } + for value in [10, -20] { + push_i32_le(&mut object_data, value); + } + for value in [1, 2, 100, 200] { + push_i32_le(&mut object_data, value); + } + for value in [ + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 2.0f32.to_bits(), + 3.0f32.to_bits(), + 4.0f32.to_bits(), + 5.0f32.to_bits(), + ] { + push_u32_le(&mut object_data, value); + } + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let color_type = strings.len(); + strings.extend_from_slice(b"ColorRGBA\0"); + let color_name = strings.len(); + strings.extend_from_slice(b"tint\0"); + let guid_type = strings.len(); + strings.extend_from_slice(b"GUID\0"); + let guid_name = strings.len(); + strings.extend_from_slice(b"guid\0"); + let vector_type = strings.len(); + strings.extend_from_slice(b"Vector3f\0"); + let vector_name = strings.len(); + strings.extend_from_slice(b"position\0"); + let vector_int_type = strings.len(); + strings.extend_from_slice(b"Vector2Int\0"); + let vector_int_name = strings.len(); + strings.extend_from_slice(b"grid\0"); + let rect_int_type = strings.len(); + strings.extend_from_slice(b"RectInt\0"); + let rect_int_name = strings.len(); + strings.extend_from_slice(b"tile_rect\0"); + let bounds_type = strings.len(); + strings.extend_from_slice(b"AABB\0"); + let bounds_name = strings.len(); + strings.extend_from_slice(b"bounds\0"); + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 7); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, color_type as i32, color_name as i32, 16), + (1u8, guid_type as i32, guid_name as i32, 16), + (1u8, vector_type as i32, vector_name as i32, 12), + (1u8, vector_int_type as i32, vector_int_name as i32, 8), + (1u8, rect_int_type as i32, rect_int_name as i32, 16), + (1u8, bounds_type as i32, bounds_name as i32, 24), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_unity_child_structs() -> Vec { + fn push_string(strings: &mut Vec, value: &[u8]) -> usize { + let offset = strings.len(); + strings.extend_from_slice(value); + strings.push(0); + offset + } + + let mut object_data = Vec::new(); + for value in [1.0f32.to_bits(), 2.0f32.to_bits(), 3.0f32.to_bits()] { + push_u32_le(&mut object_data, value); + } + for value in [10, -20] { + push_i32_le(&mut object_data, value); + } + object_data.extend(0u8..16); + + let mut strings = Vec::new(); + let root_type = push_string(&mut strings, b"MonoBehaviour"); + let root_name = push_string(&mut strings, b""); + let vector_type = push_string(&mut strings, b"Vector3f"); + let vector_name = push_string(&mut strings, b"position"); + let float_type = push_string(&mut strings, b"float"); + let x_name = push_string(&mut strings, b"x"); + let y_name = push_string(&mut strings, b"y"); + let z_name = push_string(&mut strings, b"z"); + let vector_int_type = push_string(&mut strings, b"Vector2Int"); + let vector_int_name = push_string(&mut strings, b"grid"); + let int_type = push_string(&mut strings, b"int"); + let guid_type = push_string(&mut strings, b"GUID"); + let guid_name = push_string(&mut strings, b"guid"); + let uint8_type = push_string(&mut strings, b"UInt8"); + let byte_names = (0..16) + .map(|index| push_string(&mut strings, format!("data{index}").as_bytes())) + .collect::>(); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 25); + push_i32_le(&mut metadata, strings.len() as i32); + + let mut nodes = vec![ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, vector_type as i32, vector_name as i32, 12), + (2u8, float_type as i32, x_name as i32, 4), + (2u8, float_type as i32, y_name as i32, 4), + (2u8, float_type as i32, z_name as i32, 4), + (1u8, vector_int_type as i32, vector_int_name as i32, 8), + (2u8, int_type as i32, x_name as i32, 4), + (2u8, int_type as i32, y_name as i32, 4), + (1u8, guid_type as i32, guid_name as i32, 16), + ]; + nodes.extend( + byte_names + .iter() + .map(|name| (2u8, uint8_type as i32, *name as i32, 1)), + ); + + for (level, type_offset, name_offset, byte_size) in nodes { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_unknown_fixed_field() -> Vec { + let mut object_data = vec![1, 2, 3, 4]; + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let blob_type = strings.len(); + strings.extend_from_slice(b"CustomBlob\0"); + let blob_name = strings.len(); + strings.extend_from_slice(b"blob\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 2); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, blob_type as i32, blob_name as i32, 4), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.append(&mut object_data); + file + } + + fn synthetic_serialized_enum_field() -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, 2); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let enum_type = strings.len(); + strings.extend_from_slice(b"ScenarioDifficulty\0"); + let enum_name = strings.len(); + strings.extend_from_slice(b"difficulty\0"); + let value_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let value_name = strings.len(); + strings.extend_from_slice(b"value__\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 3); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, enum_type as i32, enum_name as i32, -1), + (2u8, value_type as i32, value_name as i32, 4), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_bitfield_field() -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 5); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let bitfield_type = strings.len(); + strings.extend_from_slice(b"LayerMask\0"); + let bitfield_name = strings.len(); + strings.extend_from_slice(b"target_layers\0"); + let bits_type = strings.len(); + strings.extend_from_slice(b"UInt32\0"); + let bits_name = strings.len(); + strings.extend_from_slice(b"m_Bits\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 3); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset, byte_size) in [ + (0u8, root_type as i32, root_name as i32, -1), + (1u8, bitfield_type as i32, bitfield_name as i32, -1), + (2u8, bits_type as i32, bits_name as i32, 4), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, byte_size); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + fn synthetic_serialized_string_map() -> Vec { + synthetic_serialized_string_map_with_entries(&[("jp", "hello"), ("cn", "world")]) + } + + fn synthetic_serialized_empty_string_map() -> Vec { + synthetic_serialized_string_map_with_entries(&[]) + } + + fn synthetic_serialized_string_map_with_entries(entries: &[(&str, &str)]) -> Vec { + synthetic_serialized_string_map_with_entry_names(entries, b"first", b"second") + } + + fn synthetic_serialized_key_value_string_map() -> Vec { + synthetic_serialized_string_map_with_entry_names( + &[("jp", "hello"), ("cn", "world")], + b"key", + b"value", + ) + } + + fn synthetic_serialized_scriptableobject_key_value_string_map() -> Vec { + synthetic_serialized_string_map_with_entry_names_and_root( + &[("jp", "hello"), ("cn", "world")], + b"key", + b"value", + b"ScriptableObject", + 115, + ) + } + + fn synthetic_serialized_empty_key_value_string_map() -> Vec { + synthetic_serialized_string_map_with_entry_names(&[], b"key", b"value") + } + + fn synthetic_serialized_string_map_with_entry_names( + entries: &[(&str, &str)], + first_field_name: &[u8], + second_field_name: &[u8], + ) -> Vec { + synthetic_serialized_string_map_with_entry_names_and_root( + entries, + first_field_name, + second_field_name, + b"MonoBehaviour", + 114, + ) + } + + fn synthetic_serialized_string_map_with_entry_names_and_root( + entries: &[(&str, &str)], + first_field_name: &[u8], + second_field_name: &[u8], + root_type_name: &[u8], + class_id: i32, + ) -> Vec { + let mut object_data = Vec::new(); + push_i32_le(&mut object_data, entries.len() as i32); + for (key, value) in entries { + push_u32_le(&mut object_data, key.len() as u32); + object_data.extend_from_slice(key.as_bytes()); + align(&mut object_data, 4); + push_u32_le(&mut object_data, value.len() as u32); + object_data.extend_from_slice(value.as_bytes()); + align(&mut object_data, 4); + } + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(root_type_name); + strings.push(0); + let root_name = strings.len(); + strings.push(0); + let map_type = strings.len(); + strings.extend_from_slice(b"map\0"); + let map_name = strings.len(); + strings.extend_from_slice(b"texts\0"); + let array_type = strings.len(); + strings.extend_from_slice(b"Array\0"); + let array_name = strings.len(); + strings.extend_from_slice(b"Array\0"); + let size_type = strings.len(); + strings.extend_from_slice(b"int\0"); + let size_name = strings.len(); + strings.extend_from_slice(b"size\0"); + let pair_type = strings.len(); + strings.extend_from_slice(b"pair\0"); + let data_name = strings.len(); + strings.extend_from_slice(b"data\0"); + let string_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let first_name = strings.len(); + strings.extend_from_slice(first_field_name); + strings.push(0); + let second_name = strings.len(); + strings.extend_from_slice(second_field_name); + strings.push(0); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, class_id); + metadata.push(0); + push_i16_le(&mut metadata, 0); + if class_id == 114 { + metadata.extend_from_slice(&[0; 16]); + } + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 7); + push_i32_le(&mut metadata, strings.len() as i32); + + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, map_type as i32, map_name as i32), + (2u8, array_type as i32, array_name as i32), + (3u8, size_type as i32, size_name as i32), + (3u8, pair_type as i32, data_name as i32), + (4u8, string_type as i32, first_name as i32), + (4u8, string_type as i32, second_name as i32), + ] { + push_i16_le(&mut metadata, 1); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + 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_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + #[test] fn parses_synthetic_text_asset_and_object_table() { let file = synthetic_serialized_file(); @@ -875,4 +5264,1596 @@ mod tests { assert_eq!(asset.name, "GameMainConfig"); assert_eq!(asset.bytes, b"hello"); } + + #[test] + fn decodes_monobehaviour_typetree_fields_with_offsets() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_monobehaviour()).unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "message"); + assert_eq!(fields[0].name, "message"); + assert_eq!(fields[0].type_name, "string"); + assert_eq!(fields[0].offset, 0); + assert_eq!(fields[0].byte_size, 12); + assert_eq!( + fields[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + } + + #[test] + fn decodes_typetree_covered_managed_reference_fields() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_managed_reference()).unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "entry"); + assert_eq!(fields[0].type_name, "managedReference"); + let UnitySerializedValue::ManagedReference { + type_name, + metadata, + fields: managed_fields, + bytes, + } = &fields[0].value + else { + panic!("expected managed reference value"); + }; + assert_eq!(type_name, "managedReference"); + assert!(metadata.is_none()); + assert!(bytes.is_empty()); + assert_eq!(managed_fields.len(), 1); + assert_eq!(managed_fields[0].path, "entry.message"); + assert_eq!( + managed_fields[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + } + + #[test] + fn decodes_typetree_covered_serialized_reference_alias() { + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_managed_reference_with_type(b"SerializedReference"), + ) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "entry"); + assert_eq!(fields[0].type_name, "SerializedReference"); + let UnitySerializedValue::ManagedReference { + type_name, + fields: managed_fields, + .. + } = &fields[0].value + else { + panic!("expected serialized reference alias to decode as managed reference"); + }; + assert_eq!(type_name, "SerializedReference"); + assert_eq!(managed_fields.len(), 1); + assert_eq!(managed_fields[0].path, "entry.message"); + assert_eq!( + managed_fields[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + } + + #[test] + fn decodes_managed_reference_registry_records() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_managed_reference_registry()) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "m_SerializedReferences"); + let UnitySerializedValue::ManagedReferenceRegistry { + references, + fields: registry_fields, + } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + assert_eq!(references.len(), 1); + assert_eq!(references[0].metadata.reference_id, Some(42)); + assert_eq!( + references[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + assert_eq!(references[0].metadata.namespace.as_deref(), Some("BA.Text")); + assert_eq!( + references[0].metadata.assembly_name.as_deref(), + Some("Game") + ); + assert_eq!(references[0].fields.len(), 1); + assert_eq!( + references[0].fields[0].path, + "m_SerializedReferences.references[0].data.message" + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!(registry_fields.len(), 1); + } + + #[test] + fn decodes_managed_reference_registry_alias_names() { + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_managed_reference_registry_with_names( + b"ManagedReferenceRegistry", + b"m_ManagedReferences", + b"managedReferenceFullTypeName", + b"value", + ), + ) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "m_ManagedReferences"); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!(references.len(), 1); + assert_eq!(references[0].metadata.reference_id, Some(42)); + assert_eq!( + references[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + assert_eq!(references[0].fields.len(), 1); + assert_eq!( + references[0].fields[0].path, + "m_ManagedReferences.references[0].value.message" + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + } + + #[test] + fn decodes_managed_reference_registry_refids_and_verbose_type_names() { + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_managed_reference_registry_with_detailed_names( + ManagedReferenceRegistryNames { + registry_type_name: b"ManagedReferencesRegistry", + registry_field_name: b"m_ManagedReferences", + array_field_name: b"RefIds", + rid_field_name: b"rid", + type_field_name: b"typeID", + class_field_name: b"className", + namespace_field_name: b"namespaceName", + assembly_field_name: b"asmName", + payload_field_name: b"data", + }, + ), + ) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "m_ManagedReferences"); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!(references.len(), 1); + assert_eq!(references[0].metadata.reference_id, Some(42)); + assert_eq!( + references[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + assert_eq!(references[0].metadata.namespace.as_deref(), Some("BA.Text")); + assert_eq!( + references[0].metadata.assembly_name.as_deref(), + Some("Game") + ); + assert_eq!( + references[0].fields[0].path, + "m_ManagedReferences.RefIds[0].data.message" + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + } + + #[test] + fn decodes_managed_reference_registry_prefixed_metadata_aliases() { + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_managed_reference_registry_with_detailed_names( + ManagedReferenceRegistryNames { + registry_type_name: b"SerializedReferenceRegistry", + registry_field_name: b"m_ManagedReferences", + array_field_name: b"managedReferenceIds", + rid_field_name: b"managedReferenceId", + type_field_name: b"managedReferenceType", + class_field_name: b"managedReferenceClassName", + namespace_field_name: b"managedReferenceNamespaceName", + assembly_field_name: b"managedReferenceAssemblyName", + payload_field_name: b"serializedReferenceData", + }, + ), + ) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!(references.len(), 1); + assert_eq!(references[0].metadata.reference_id, Some(42)); + assert_eq!( + references[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + assert_eq!(references[0].metadata.namespace.as_deref(), Some("BA.Text")); + assert_eq!( + references[0].metadata.assembly_name.as_deref(), + Some("Game") + ); + assert_eq!( + references[0].fields[0].path, + "m_ManagedReferences.managedReferenceIds[0].serializedReferenceData.message" + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + } + + #[test] + fn decodes_and_replaces_managed_reference_registry_payload_alias_family() { + for payload_field_name in [ + b"managedReferencePayload".as_slice(), + b"referencePayload".as_slice(), + b"serializedReferencePayload".as_slice(), + b"managedReferenceValue".as_slice(), + b"referenceValue".as_slice(), + b"serializedReferenceValue".as_slice(), + b"managedReferenceObject".as_slice(), + b"referenceObject".as_slice(), + b"serializedReferenceObject".as_slice(), + ] { + let payload_field_name = std::str::from_utf8(payload_field_name).unwrap(); + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_managed_reference_registry_with_names( + b"ManagedReferenceRegistry", + b"m_ManagedReferences", + b"managedReferenceFullTypeName", + payload_field_name.as_bytes(), + ), + ) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = + &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!(references.len(), 1); + assert_eq!( + references[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + let expected_path = + format!("m_ManagedReferences.references[0].{payload_field_name}.message"); + assert_eq!(references[0].fields[0].path, expected_path); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + + let rewritten = parsed + .replace_string_field(1, &expected_path, Some("こんにちは"), "你好") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = + &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("你好".to_string()) + ); + } + } + + #[test] + fn collects_managed_reference_records_with_id_aliases_and_multiple_payloads() { + let fields = vec![UnitySerializedField { + path: "m_ManagedReferences.m_RefIds".to_string(), + name: "m_RefIds".to_string(), + type_name: "Array".to_string(), + offset: 0, + byte_size: 128, + type_tree_node_index: None, + value: UnitySerializedValue::Array(vec![ + managed_reference_record_fixture( + 42, + "Game BA.Text.ScenarioLine", + "m_ManagedReferences.m_RefIds[0].serializedData.message", + "こんにちは", + ), + managed_reference_record_fixture( + 43, + "Game BA.Text.ChoiceLine", + "m_ManagedReferences.m_RefIds[1].referenceData.message", + "選択肢", + ), + ]), + }]; + + let records = managed_reference_records_from_fields(&fields); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].metadata.reference_id, Some(42)); + assert_eq!( + records[0].metadata.type_name.as_deref(), + Some("ScenarioLine") + ); + assert_eq!( + records[0].fields[0].path, + "m_ManagedReferences.m_RefIds[0].serializedData.message" + ); + assert_eq!(records[1].metadata.reference_id, Some(43)); + assert_eq!(records[1].metadata.type_name.as_deref(), Some("ChoiceLine")); + assert_eq!( + records[1].fields[0].path, + "m_ManagedReferences.m_RefIds[1].referenceData.message" + ); + } + + #[test] + fn decodes_managed_reference_registry_managed_reference_data_payload() { + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_managed_reference_registry_with_names( + b"managedReferencesRegistry", + b"m_SerializedReferences", + b"type", + b"managedReferenceData", + ), + ) + .unwrap(); + + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!(references.len(), 1); + assert_eq!( + references[0].fields[0].path, + "m_SerializedReferences.references[0].managedReferenceData.message" + ); + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + + let rewritten = parsed + .replace_string_field( + 1, + "m_SerializedReferences.references[0].managedReferenceData.message", + Some("こんにちは"), + "你好", + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("你好".to_string()) + ); + } + + #[test] + fn parses_managed_reference_full_typename_variants() { + let unity_full_name = parse_managed_reference_type_name("Game BA.Text.ScenarioLine"); + assert_eq!( + unity_full_name, + ParsedManagedReferenceTypeName { + type_name: Some("ScenarioLine".to_string()), + namespace: Some("BA.Text".to_string()), + assembly_name: Some("Game".to_string()), + } + ); + + let dotnet_full_name = parse_managed_reference_type_name("BA.Text.ScenarioLine, Game"); + assert_eq!( + dotnet_full_name, + ParsedManagedReferenceTypeName { + type_name: Some("ScenarioLine".to_string()), + namespace: Some("BA.Text".to_string()), + assembly_name: Some("Game".to_string()), + } + ); + + let class_only = parse_managed_reference_type_name("ScenarioLine"); + assert_eq!( + class_only, + ParsedManagedReferenceTypeName { + type_name: Some("ScenarioLine".to_string()), + namespace: None, + assembly_name: None, + } + ); + + let metadata = managed_reference_metadata_from_fields(&[UnitySerializedField { + path: "m_ManagedReferences.references[0].managedReferenceFullTypeName".to_string(), + name: "managedReferenceFullTypeName".to_string(), + type_name: "string".to_string(), + offset: 0, + byte_size: 32, + type_tree_node_index: None, + value: UnitySerializedValue::String("Game BA.Text.ScenarioLine".to_string()), + }]) + .unwrap(); + assert_eq!( + metadata.full_type_name.as_deref(), + Some("Game BA.Text.ScenarioLine") + ); + assert_eq!(metadata.type_name.as_deref(), Some("ScenarioLine")); + assert_eq!(metadata.namespace.as_deref(), Some("BA.Text")); + assert_eq!(metadata.assembly_name.as_deref(), Some("Game")); + } + + fn managed_reference_record_fixture( + reference_id: i64, + full_type_name: &str, + payload_path: &str, + payload_text: &str, + ) -> UnitySerializedField { + let (payload_parent, payload_name) = payload_path + .rsplit_once('.') + .map(|(path, name)| (path.to_string(), name.to_string())) + .unwrap_or_else(|| (payload_path.to_string(), String::new())); + let payload_field_name = if payload_parent.contains("referenceData") { + "referenceData" + } else { + "serializedData" + }; + UnitySerializedField { + path: payload_parent.clone(), + name: "data".to_string(), + type_name: "ManagedReferenceEntry".to_string(), + offset: 0, + byte_size: 64, + type_tree_node_index: None, + value: UnitySerializedValue::Object(vec![ + UnitySerializedField { + path: "id".to_string(), + name: "id".to_string(), + type_name: "long long".to_string(), + offset: 0, + byte_size: 8, + type_tree_node_index: None, + value: UnitySerializedValue::Signed(reference_id), + }, + UnitySerializedField { + path: "typeInfo".to_string(), + name: "typeInfo".to_string(), + type_name: "string".to_string(), + offset: 8, + byte_size: full_type_name.len() + 4, + type_tree_node_index: None, + value: UnitySerializedValue::String(full_type_name.to_string()), + }, + UnitySerializedField { + path: payload_parent, + name: payload_field_name.to_string(), + type_name: "managedReference".to_string(), + offset: 32, + byte_size: payload_text.len() + 4, + type_tree_node_index: None, + value: UnitySerializedValue::Object(vec![UnitySerializedField { + path: payload_path.to_string(), + name: payload_name, + type_name: "string".to_string(), + offset: 32, + byte_size: payload_text.len() + 4, + type_tree_node_index: None, + value: UnitySerializedValue::String(payload_text.to_string()), + }]), + }, + ]), + } + } + + #[test] + fn replaces_managed_reference_registry_payload_string_field() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_managed_reference_registry()) + .unwrap(); + + let rewritten = parsed + .replace_string_field( + 1, + "m_SerializedReferences.references[0].data.message", + Some("こんにちは"), + "你好", + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReferenceRegistry { references, .. } = &fields[0].value + else { + panic!("expected managed reference registry value"); + }; + + assert_eq!( + references[0].fields[0].value, + UnitySerializedValue::String("你好".to_string()) + ); + } + + #[test] + fn replaces_monobehaviour_string_field_and_updates_object_size() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_monobehaviour()).unwrap(); + + let rewritten = parsed + .replace_string_field(1, "message", Some("hello"), "こんにちは") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + + assert_eq!( + fields[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!( + u64::from_be_bytes(rewritten[24..32].try_into().unwrap()) as usize, + rewritten.len() + ); + } + + #[test] + fn replaces_managed_reference_string_field() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_managed_reference()).unwrap(); + + let rewritten = parsed + .replace_string_field(1, "entry.message", Some("hello"), "world") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::ManagedReference { + fields: managed_fields, + .. + } = &fields[0].value + else { + panic!("expected managed reference value"); + }; + + assert_eq!( + managed_fields[0].value, + UnitySerializedValue::String("world".to_string()) + ); + } + + #[test] + fn decodes_and_replaces_string_array_elements_with_offsets() { + let parsed = UnitySerializedFile::from_slice(&synthetic_serialized_string_array()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "messages"); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!(items[0].offset, 4); + assert_eq!(items[0].byte_size, 12); + assert_eq!( + items[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + assert_eq!(items[1].path, "messages[1]"); + assert_eq!(items[1].offset, 16); + assert_eq!( + items[1].value, + UnitySerializedValue::String("world".to_string()) + ); + + let rewritten = parsed + .replace_string_field(1, "messages[1]", Some("world"), "老師") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + + assert_eq!( + items[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + } + + #[test] + fn replaces_whole_string_array_with_length_change() { + let parsed = UnitySerializedFile::from_slice(&synthetic_serialized_string_array()).unwrap(); + + let rewritten = parsed + .replace_field_value( + 1, + "messages", + Some(&UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("hello".to_string()), + UnitySerializedReplacementValue::String("world".to_string()), + ])), + &UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + UnitySerializedReplacementValue::String("文本".to_string()), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!( + items[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!(items[1].path, "messages[1]"); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + assert_eq!(items[2].path, "messages[2]"); + assert_eq!( + items[2].value, + UnitySerializedValue::String("文本".to_string()) + ); + assert_eq!( + u64::from_be_bytes(rewritten[24..32].try_into().unwrap()) as usize, + rewritten.len() + ); + } + + #[test] + fn replaces_empty_string_array_from_typetree_schema() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_empty_string_array()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + assert!(items.is_empty()); + + let rewritten = parsed + .replace_field_value( + 1, + "messages", + Some(&UnitySerializedReplacementValue::Array(vec![])), + &UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected string array"); + }; + + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!( + items[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!(items[1].path, "messages[1]"); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + } + + #[test] + fn decodes_and_replaces_nested_vector_array_shape() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_vector_string_array()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "messages"); + assert_eq!(fields[0].type_name, "vector"); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected vector string array"); + }; + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!(items[0].name, "messages[0]"); + assert_eq!(items[0].offset, 4); + assert_eq!(items[0].byte_size, 12); + assert_eq!( + items[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + assert_eq!(items[1].path, "messages[1]"); + assert_eq!(items[1].offset, 16); + assert_eq!( + items[1].value, + UnitySerializedValue::String("world".to_string()) + ); + + let rewritten = parsed + .replace_string_field(1, "messages[1]", Some("world"), "老師") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected vector string array"); + }; + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + + let rewritten = reparsed + .replace_field_value( + 1, + "messages", + None, + &UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + UnitySerializedReplacementValue::String("文本".to_string()), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected vector string array"); + }; + assert_eq!(items.len(), 3); + assert_eq!( + items[2].value, + UnitySerializedValue::String("文本".to_string()) + ); + } + + #[test] + fn decodes_and_replaces_list_string_collection_alias() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_list_string_array()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "messages"); + assert_eq!(fields[0].type_name, "List"); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected List to decode as array"); + }; + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!( + items[0].value, + UnitySerializedValue::String("hello".to_string()) + ); + assert_eq!( + items[1].value, + UnitySerializedValue::String("world".to_string()) + ); + + let rewritten = parsed + .replace_string_field(1, "messages[1]", Some("world"), "老師") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected List to decode as array"); + }; + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + } + + #[test] + fn replaces_empty_nested_vector_array_from_typetree_schema() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_empty_vector_string_array()) + .unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected empty vector string array"); + }; + assert!(items.is_empty()); + + let rewritten = parsed + .replace_field_value( + 1, + "messages", + Some(&UnitySerializedReplacementValue::Array(vec![])), + &UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected vector string array"); + }; + + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!( + items[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!(items[1].path, "messages[1]"); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + } + + #[test] + fn replaces_empty_hashset_string_collection_alias_from_typetree_schema() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_empty_hashset_string_array()) + .unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + assert_eq!(fields[0].type_name, "HashSet"); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected empty HashSet to decode as array"); + }; + assert!(items.is_empty()); + + let rewritten = parsed + .replace_field_value( + 1, + "messages", + Some(&UnitySerializedReplacementValue::Array(vec![])), + &UnitySerializedReplacementValue::Array(vec![ + UnitySerializedReplacementValue::String("こんにちは".to_string()), + UnitySerializedReplacementValue::String("老師".to_string()), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected HashSet to decode as array"); + }; + + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "messages[0]"); + assert_eq!( + items[0].value, + UnitySerializedValue::String("こんにちは".to_string()) + ); + assert_eq!( + items[1].value, + UnitySerializedValue::String("老師".to_string()) + ); + } + + #[test] + fn recognizes_common_collection_container_type_aliases() { + assert!(is_collection_container_type("vector")); + assert!(is_collection_container_type("staticvector")); + assert!(is_collection_container_type("List")); + assert!(is_collection_container_type("HashSet")); + assert!(is_collection_container_type( + "System.Collections.Generic.List" + )); + assert!(is_collection_container_type( + "System.Collections.Generic.HashSet" + )); + assert!(!is_collection_container_type("PlaylistConfig")); + } + + #[test] + fn decodes_and_replaces_whole_string_map_with_length_change() { + let parsed = UnitySerializedFile::from_slice(&synthetic_serialized_string_map()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected string map"); + }; + assert_eq!(fields[0].path, "texts"); + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "texts[0]"); + let UnitySerializedValue::Object(entry_fields) = &items[0].value else { + panic!("expected map entry object"); + }; + assert_eq!(entry_fields[0].path, "texts[0].first"); + assert_eq!( + entry_fields[0].value, + UnitySerializedValue::String("jp".to_string()) + ); + assert_eq!( + entry_fields[1].value, + UnitySerializedValue::String("hello".to_string()) + ); + + let rewritten = parsed + .replace_field_value( + 1, + "texts", + Some(&UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement("jp", "hello"), + map_entry_replacement("cn", "world"), + ])), + &UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement("jp", "こんにちは"), + map_entry_replacement("cn", "老師"), + map_entry_replacement("tw", "文本"), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected string map"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + assert_eq!(map_entry_strings(&items[2]), ("tw", "文本")); + } + + #[test] + fn replaces_empty_string_map_from_typetree_schema() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_empty_string_map()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected string map"); + }; + assert!(items.is_empty()); + + let rewritten = parsed + .replace_field_value( + 1, + "texts", + Some(&UnitySerializedReplacementValue::Map(vec![])), + &UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement("jp", "こんにちは"), + map_entry_replacement("cn", "老師"), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected string map"); + }; + + assert_eq!(items.len(), 2); + assert_eq!(items[0].path, "texts[0]"); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(items[1].path, "texts[1]"); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + } + + #[test] + fn decodes_and_replaces_key_value_string_map_schema() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_key_value_string_map()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected key/value string map"); + }; + assert_eq!(items.len(), 2); + let UnitySerializedValue::Object(entry_fields) = &items[0].value else { + panic!("expected key/value map entry object"); + }; + assert_eq!(entry_fields[0].path, "texts[0].key"); + assert_eq!(entry_fields[1].path, "texts[0].value"); + + let rewritten = parsed + .replace_field_value( + 1, + "texts", + Some(&UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "hello"), + map_entry_replacement_with_names("key", "value", "cn", "world"), + ])), + &UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "こんにちは"), + map_entry_replacement_with_names("key", "value", "cn", "老師"), + map_entry_replacement_with_names("key", "value", "tw", "文本"), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected key/value string map"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + assert_eq!(map_entry_strings(&items[2]), ("tw", "文本")); + } + + #[test] + fn decodes_and_replaces_scriptableobject_key_value_string_map_schema() { + let parsed = UnitySerializedFile::from_slice( + &synthetic_serialized_scriptableobject_key_value_string_map(), + ) + .unwrap(); + assert_eq!(parsed.types[0].class_id, 115); + assert_eq!(parsed.types[0].type_tree[0].type_name, "ScriptableObject"); + assert_eq!(parsed.objects[0].class_id, 115); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected ScriptableObject key/value string map"); + }; + assert_eq!(items.len(), 2); + assert_eq!(map_entry_strings(&items[0]), ("jp", "hello")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "world")); + + let rewritten = parsed + .replace_field_value( + 1, + "texts", + Some(&UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "hello"), + map_entry_replacement_with_names("key", "value", "cn", "world"), + ])), + &UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "こんにちは"), + map_entry_replacement_with_names("key", "value", "cn", "老師"), + map_entry_replacement_with_names("key", "value", "tw", "文本"), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected ScriptableObject key/value string map"); + }; + + assert_eq!(items.len(), 3); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + assert_eq!(map_entry_strings(&items[2]), ("tw", "文本")); + } + + #[test] + fn replaces_empty_key_value_string_map_from_typetree_schema() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_empty_key_value_string_map()) + .unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected empty key/value string map"); + }; + assert!(items.is_empty()); + + let rewritten = parsed + .replace_field_value( + 1, + "texts", + Some(&UnitySerializedReplacementValue::Map(vec![])), + &UnitySerializedReplacementValue::Map(vec![ + map_entry_replacement_with_names("key", "value", "jp", "こんにちは"), + map_entry_replacement_with_names("key", "value", "cn", "老師"), + ]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Map(items) = &fields[0].value else { + panic!("expected key/value string map"); + }; + + assert_eq!(items.len(), 2); + assert_eq!(map_entry_strings(&items[0]), ("jp", "こんにちは")); + assert_eq!(map_entry_strings(&items[1]), ("cn", "老師")); + } + + #[test] + fn replaces_signed_array_element_with_semantic_value() { + let parsed = UnitySerializedFile::from_slice(&synthetic_serialized_int_array()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected int array"); + }; + assert_eq!(items[0].path, "scores[0]"); + assert_eq!(items[0].offset, 4); + assert_eq!(items[0].value, UnitySerializedValue::Signed(10)); + assert_eq!(items[1].path, "scores[1]"); + assert_eq!(items[1].offset, 8); + assert_eq!(items[1].value, UnitySerializedValue::Signed(20)); + + let rewritten = parsed + .replace_field_value( + 1, + "scores[1]", + Some(&UnitySerializedReplacementValue::Signed(20)), + &UnitySerializedReplacementValue::Signed(42), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + let UnitySerializedValue::Array(items) = &fields[0].value else { + panic!("expected int array"); + }; + + assert_eq!(items[0].value, UnitySerializedValue::Signed(10)); + assert_eq!(items[1].value, UnitySerializedValue::Signed(42)); + } + + #[test] + fn decodes_and_replaces_unity_fixed_leaf_structs() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_unity_leaf_structs()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 6); + assert_eq!(fields[0].path, "tint"); + assert_eq!( + fields[0].value, + UnitySerializedValue::Float32Struct { + type_name: "ColorRGBA".to_string(), + values: vec![ + 1.0f32.to_bits(), + 0.5f32.to_bits(), + 0.25f32.to_bits(), + 1.0f32.to_bits(), + ], + } + ); + assert_eq!(fields[1].path, "guid"); + assert_eq!( + fields[1].value, + UnitySerializedValue::FixedBytes { + type_name: "GUID".to_string(), + bytes: (0u8..16).collect(), + } + ); + assert_eq!(fields[2].path, "position"); + assert_eq!( + fields[2].value, + UnitySerializedValue::Float32Struct { + type_name: "Vector3f".to_string(), + values: vec![1.0f32.to_bits(), 2.0f32.to_bits(), 3.0f32.to_bits()], + } + ); + assert_eq!(fields[3].path, "grid"); + assert_eq!( + fields[3].value, + UnitySerializedValue::Int32Struct { + type_name: "Vector2Int".to_string(), + values: vec![10, -20], + } + ); + assert_eq!(fields[4].path, "tile_rect"); + assert_eq!( + fields[4].value, + UnitySerializedValue::Int32Struct { + type_name: "RectInt".to_string(), + values: vec![1, 2, 100, 200], + } + ); + assert_eq!(fields[5].path, "bounds"); + assert_eq!( + fields[5].value, + UnitySerializedValue::Float32Struct { + type_name: "AABB".to_string(), + values: vec![ + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 2.0f32.to_bits(), + 3.0f32.to_bits(), + 4.0f32.to_bits(), + 5.0f32.to_bits(), + ], + } + ); + let rewritten_position = parsed + .replace_field_value( + 1, + "position", + Some(&UnitySerializedReplacementValue::Float32Struct { + type_name: "Vector3f".to_string(), + values: vec![1.0f32.to_bits(), 2.0f32.to_bits(), 3.0f32.to_bits()], + }), + &UnitySerializedReplacementValue::Float32Struct { + type_name: "Vector3f".to_string(), + values: vec![4.0f32.to_bits(), 5.0f32.to_bits(), 6.0f32.to_bits()], + }, + ) + .unwrap(); + let reparsed_position = UnitySerializedFile::from_slice(&rewritten_position).unwrap(); + let fields = reparsed_position.fields_for_object(1).unwrap(); + assert_eq!( + fields[2].value, + UnitySerializedValue::Float32Struct { + type_name: "Vector3f".to_string(), + values: vec![4.0f32.to_bits(), 5.0f32.to_bits(), 6.0f32.to_bits()], + } + ); + + let replacement_guid: Vec = (16u8..32).collect(); + let rewritten_guid = parsed + .replace_field_value( + 1, + "guid", + Some(&UnitySerializedReplacementValue::FixedBytes { + type_name: "GUID".to_string(), + bytes: (0u8..16).collect(), + }), + &UnitySerializedReplacementValue::FixedBytes { + type_name: "GUID".to_string(), + bytes: replacement_guid.clone(), + }, + ) + .unwrap(); + let reparsed_guid = UnitySerializedFile::from_slice(&rewritten_guid).unwrap(); + let fields = reparsed_guid.fields_for_object(1).unwrap(); + assert_eq!( + fields[1].value, + UnitySerializedValue::FixedBytes { + type_name: "GUID".to_string(), + bytes: replacement_guid, + } + ); + + let rewritten_grid = parsed + .replace_field_value( + 1, + "grid", + Some(&UnitySerializedReplacementValue::Int32Struct { + type_name: "Vector2Int".to_string(), + values: vec![10, -20], + }), + &UnitySerializedReplacementValue::Int32Struct { + type_name: "Vector2Int".to_string(), + values: vec![30, 40], + }, + ) + .unwrap(); + let reparsed_grid = UnitySerializedFile::from_slice(&rewritten_grid).unwrap(); + let fields = reparsed_grid.fields_for_object(1).unwrap(); + assert_eq!( + fields[3].value, + UnitySerializedValue::Int32Struct { + type_name: "Vector2Int".to_string(), + values: vec![30, 40], + } + ); + } + + #[test] + fn replaces_child_shaped_unity_structs_with_semantic_values() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_unity_child_structs()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].path, "position"); + let UnitySerializedValue::Object(position_fields) = &fields[0].value else { + panic!("expected child-shaped Vector3f object"); + }; + assert_eq!(position_fields[0].path, "position.x"); + assert_eq!( + position_fields[0].value, + UnitySerializedValue::Float32(1.0f32.to_bits()) + ); + assert_eq!(position_fields[1].path, "position.y"); + assert_eq!(position_fields[2].path, "position.z"); + let rewritten_position = parsed + .replace_field_value( + 1, + "position", + Some(&UnitySerializedReplacementValue::Float32Struct { + type_name: "Vector3f".to_string(), + values: vec![1.0f32.to_bits(), 2.0f32.to_bits(), 3.0f32.to_bits()], + }), + &UnitySerializedReplacementValue::Float32Struct { + type_name: "Vector3f".to_string(), + values: vec![4.0f32.to_bits(), 5.0f32.to_bits(), 6.0f32.to_bits()], + }, + ) + .unwrap(); + let reparsed_position = UnitySerializedFile::from_slice(&rewritten_position).unwrap(); + let fields = reparsed_position.fields_for_object(1).unwrap(); + let UnitySerializedValue::Object(position_fields) = &fields[0].value else { + panic!("expected child-shaped Vector3f object"); + }; + assert_eq!( + position_fields[0].value, + UnitySerializedValue::Float32(4.0f32.to_bits()) + ); + assert_eq!( + position_fields[1].value, + UnitySerializedValue::Float32(5.0f32.to_bits()) + ); + assert_eq!( + position_fields[2].value, + UnitySerializedValue::Float32(6.0f32.to_bits()) + ); + + let rewritten_grid = parsed + .replace_field_value( + 1, + "grid", + Some(&UnitySerializedReplacementValue::Int32Struct { + type_name: "Vector2Int".to_string(), + values: vec![10, -20], + }), + &UnitySerializedReplacementValue::Int32Struct { + type_name: "Vector2Int".to_string(), + values: vec![30, 40], + }, + ) + .unwrap(); + let reparsed_grid = UnitySerializedFile::from_slice(&rewritten_grid).unwrap(); + let fields = reparsed_grid.fields_for_object(1).unwrap(); + let UnitySerializedValue::Object(grid_fields) = &fields[1].value else { + panic!("expected child-shaped Vector2Int object"); + }; + assert_eq!(grid_fields[0].value, UnitySerializedValue::Signed(30)); + assert_eq!(grid_fields[1].value, UnitySerializedValue::Signed(40)); + + let replacement_guid: Vec = (16u8..32).collect(); + let rewritten_guid = parsed + .replace_field_value( + 1, + "guid", + Some(&UnitySerializedReplacementValue::FixedBytes { + type_name: "GUID".to_string(), + bytes: (0u8..16).collect(), + }), + &UnitySerializedReplacementValue::FixedBytes { + type_name: "GUID".to_string(), + bytes: replacement_guid.clone(), + }, + ) + .unwrap(); + let reparsed_guid = UnitySerializedFile::from_slice(&rewritten_guid).unwrap(); + let fields = reparsed_guid.fields_for_object(1).unwrap(); + let UnitySerializedValue::Object(guid_fields) = &fields[2].value else { + panic!("expected child-shaped GUID object"); + }; + let actual_guid = guid_fields + .iter() + .map(|field| match field.value { + UnitySerializedValue::Unsigned(value) => value as u8, + _ => panic!("expected UInt8 GUID field"), + }) + .collect::>(); + assert_eq!(actual_guid, replacement_guid); + } + + #[test] + fn replaces_unknown_fixed_field_with_same_length_bytes() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_unknown_fixed_field()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "blob"); + assert_eq!( + fields[0].value, + UnitySerializedValue::Unknown { + type_name: "CustomBlob".to_string(), + bytes: vec![1, 2, 3, 4], + } + ); + + let rewritten = parsed + .replace_field_value( + 1, + "blob", + Some(&UnitySerializedReplacementValue::Bytes(vec![1, 2, 3, 4])), + &UnitySerializedReplacementValue::Bytes(vec![9, 8, 7, 6]), + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + assert_eq!( + fields[0].value, + UnitySerializedValue::Unknown { + type_name: "CustomBlob".to_string(), + bytes: vec![9, 8, 7, 6], + } + ); + + let error = parsed + .replace_field_value( + 1, + "blob", + None, + &UnitySerializedReplacementValue::Bytes(vec![1, 2, 3]), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("must match current byte length")); + } + + #[test] + fn decodes_and_replaces_enum_field_with_semantic_value() { + let parsed = UnitySerializedFile::from_slice(&synthetic_serialized_enum_field()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "difficulty"); + assert_eq!(fields[0].type_name, "ScenarioDifficulty"); + assert_eq!(fields[0].byte_size, 4); + assert_eq!( + fields[0].value, + UnitySerializedValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 2, + } + ); + + let rewritten = parsed + .replace_field_value( + 1, + "difficulty", + Some(&UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 2, + }), + &UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 3, + }, + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + + assert_eq!( + fields[0].value, + UnitySerializedValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 3, + } + ); + } + + #[test] + fn decodes_and_replaces_layer_mask_bitfield_with_semantic_value() { + let parsed = + UnitySerializedFile::from_slice(&synthetic_serialized_bitfield_field()).unwrap(); + let fields = parsed.fields_for_object(1).unwrap(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, "target_layers"); + assert_eq!(fields[0].type_name, "LayerMask"); + assert_eq!(fields[0].byte_size, 4); + assert_eq!( + fields[0].value, + UnitySerializedValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 5, + } + ); + + let rewritten = parsed + .replace_field_value( + 1, + "target_layers", + Some(&UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 5, + }), + &UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 9, + }, + ) + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + let fields = reparsed.fields_for_object(1).unwrap(); + + assert_eq!( + fields[0].value, + UnitySerializedValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 9, + } + ); + } + + fn map_entry_replacement(key: &str, value: &str) -> UnitySerializedReplacementValue { + map_entry_replacement_with_names("first", "second", key, value) + } + + fn map_entry_replacement_with_names( + key_name: &str, + value_name: &str, + key: &str, + value: &str, + ) -> UnitySerializedReplacementValue { + UnitySerializedReplacementValue::Object(vec![ + UnitySerializedFieldReplacement { + name: key_name.to_string(), + value: UnitySerializedReplacementValue::String(key.to_string()), + }, + UnitySerializedFieldReplacement { + name: value_name.to_string(), + value: UnitySerializedReplacementValue::String(value.to_string()), + }, + ]) + } + + fn map_entry_strings(entry: &UnitySerializedField) -> (&str, &str) { + let UnitySerializedValue::Object(fields) = &entry.value else { + panic!("expected map entry object"); + }; + let UnitySerializedValue::String(key) = &fields[0].value else { + panic!("expected string key"); + }; + let UnitySerializedValue::String(value) = &fields[1].value else { + panic!("expected string value"); + }; + (key, value) + } + + #[test] + fn replaces_text_asset_and_updates_file_size() { + let parsed = UnitySerializedFile::from_slice(&synthetic_serialized_file()).unwrap(); + + let rewritten = parsed + .replace_text_asset(1, Some("GameMainConfig"), b"rewritten text") + .unwrap(); + let reparsed = UnitySerializedFile::from_slice(&rewritten).unwrap(); + + assert_eq!( + reparsed.text_asset("GameMainConfig").unwrap().bytes, + b"rewritten text" + ); + assert_eq!( + u64::from_be_bytes(rewritten[24..32].try_into().unwrap()) as usize, + rewritten.len() + ); + } } diff --git a/crates/bat-assetbundle/src/text.rs b/crates/bat-assetbundle/src/text.rs new file mode 100644 index 0000000..ec4973e --- /dev/null +++ b/crates/bat-assetbundle/src/text.rs @@ -0,0 +1,949 @@ +//! Text extraction from parsed Unity serialized objects. + +use crate::serialized::{ + managed_reference_metadata_from_fields, UnityManagedReferenceMetadata, + UnityManagedReferenceRecord, UnitySerializedField, UnitySerializedValue, +}; +use crate::types::ParsedAssetBundle; +use std::collections::BTreeMap; + +/// One text unit used by translation, glossary and patch pipelines. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TextUnit { + /// Original source text. + pub source_text: String, + /// Logical AssetBundle path, when provided by the caller. + pub bundle_path: Option, + /// ZIP/archive entry containing the bundle, when known. + pub archive_entry: Option, + /// Unity serialized file path. + pub serialized_file: Option, + /// Unity object path ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_id: Option, + /// Unity class ID, for example `49` for `TextAsset`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class_id: Option, + /// TypeTree field path. `TextAsset` is used for a whole TextAsset payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_path: Option, + /// Byte offset relative to the beginning of the Unity object payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_offset: Option, + /// Number of bytes consumed by this field, including alignment padding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_byte_size: Option, + /// Unity version associated with the source. + pub version: String, + /// Stable context for format, asset name and extraction details. + pub context: BTreeMap, +} + +/// Non-fatal diagnostic generated while extracting text units. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TextUnitExtractionError { + /// Serialized file containing the failed object. + pub serialized_file: Option, + /// Object path ID, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_id: Option, + /// Unity class ID, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class_id: Option, + /// TypeTree field path, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_path: Option, + /// Byte offset relative to the beginning of the Unity object payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Human-readable error. + pub error: String, +} + +/// Result of extracting text units from one parsed bundle. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TextUnitExtractionReport { + /// Extracted text units in deterministic traversal order. + pub units: Vec, + /// Non-fatal object-level errors. + pub errors: Vec, + /// TextAsset payloads that were binary or invalid UTF-8. + pub skipped_binary_text_assets: usize, +} + +/// Extracts translation-ready text from Unity bundle data. +#[derive(Debug, Default, Clone, Copy)] +pub struct TextUnitExtractor; + +impl TextUnitExtractor { + /// Creates an extractor. + pub fn new() -> Self { + Self + } + + /// Extracts TextAsset and TypeTree string fields from a parsed bundle. + pub fn extract_bundle( + &self, + bundle: &ParsedAssetBundle, + bundle_path: Option<&str>, + ) -> TextUnitExtractionReport { + self.extract_bundle_with_context(bundle, bundle_path, None) + } + + /// Extracts text with both logical bundle and archive-entry context. + pub fn extract_bundle_with_context( + &self, + bundle: &ParsedAssetBundle, + bundle_path: Option<&str>, + archive_entry: Option<&str>, + ) -> TextUnitExtractionReport { + let mut report = TextUnitExtractionReport { + units: Vec::new(), + errors: Vec::new(), + skipped_binary_text_assets: 0, + }; + + for asset in &bundle.text_assets { + if let Some((format, text)) = decode_text_payload(&asset.bytes) { + let mut context = BTreeMap::new(); + context.insert("format".to_string(), format.to_string()); + context.insert("asset_name".to_string(), asset.name.clone()); + context.insert("source_kind".to_string(), "TextAsset".to_string()); + report.units.push(TextUnit { + source_text: text, + bundle_path: bundle_path.map(ToOwned::to_owned), + archive_entry: archive_entry.map(ToOwned::to_owned), + serialized_file: asset.source_path.clone(), + path_id: Some(asset.path_id), + class_id: Some(49), + field_path: Some("TextAsset".to_string()), + field_offset: None, + field_byte_size: None, + version: bundle.unity_version.clone(), + context, + }); + } else { + report.skipped_binary_text_assets += 1; + } + } + + for serialized_file in &bundle.serialized_files { + for object in &serialized_file.objects { + if object.class_id == 49 || !serialized_file.object_has_type_tree(object) { + continue; + } + let fields = match serialized_file.fields_for_object_entry(object) { + Ok(fields) => fields, + Err(error) => { + report.errors.push(TextUnitExtractionError { + serialized_file: serialized_file.source_path.clone(), + path_id: Some(object.path_id), + class_id: Some(object.class_id), + field_path: None, + offset: None, + error: error.to_string(), + }); + continue; + } + }; + let context = FieldTextContext { + serialized_file_path: serialized_file.source_path.as_deref(), + path_id: object.path_id, + class_id: object.class_id, + version: &bundle.unity_version, + bundle_path, + archive_entry, + managed_reference: None, + }; + for field in fields { + collect_field_text(&mut report.units, &context, &field); + } + } + } + + report + } +} + +/// Serializes text units as one stable JSON object per line. +pub fn text_units_to_jsonl(units: &[TextUnit]) -> Result { + let mut output = String::new(); + for unit in units { + output.push_str(&serde_json::to_string(unit)?); + output.push('\n'); + } + Ok(output) +} + +#[derive(Clone)] +struct FieldTextContext<'a> { + serialized_file_path: Option<&'a str>, + path_id: i64, + class_id: i32, + version: &'a str, + bundle_path: Option<&'a str>, + archive_entry: Option<&'a str>, + managed_reference: Option, +} + +impl<'a> FieldTextContext<'a> { + fn with_managed_reference(&self, metadata: Option<&UnityManagedReferenceMetadata>) -> Self { + Self { + serialized_file_path: self.serialized_file_path, + path_id: self.path_id, + class_id: self.class_id, + version: self.version, + bundle_path: self.bundle_path, + archive_entry: self.archive_entry, + managed_reference: metadata.cloned().or_else(|| self.managed_reference.clone()), + } + } +} + +fn collect_field_text<'a>( + units: &mut Vec, + context: &FieldTextContext<'a>, + field: &'a UnitySerializedField, +) { + match &field.value { + UnitySerializedValue::String(text) if !text.is_empty() => { + let mut unit_context = BTreeMap::new(); + unit_context.insert("format".to_string(), "plain".to_string()); + unit_context.insert( + "source_kind".to_string(), + if context.managed_reference.is_some() { + "ManagedReferenceField" + } else { + "TypeTreeField" + } + .to_string(), + ); + unit_context.insert("type_name".to_string(), field.type_name.clone()); + if let Some(metadata) = &context.managed_reference { + insert_managed_reference_context(&mut unit_context, metadata); + } + units.push(TextUnit { + source_text: text.clone(), + bundle_path: context.bundle_path.map(ToOwned::to_owned), + archive_entry: context.archive_entry.map(ToOwned::to_owned), + serialized_file: context.serialized_file_path.map(ToOwned::to_owned), + path_id: Some(context.path_id), + class_id: Some(context.class_id), + field_path: Some(field.path.clone()), + field_offset: Some(field.offset), + field_byte_size: Some(field.byte_size), + version: context.version.to_string(), + context: unit_context, + }); + } + UnitySerializedValue::Object(fields) => { + for field in fields { + collect_field_text(units, context, field); + } + } + UnitySerializedValue::ManagedReference { + metadata, fields, .. + } => { + let managed_context = context.with_managed_reference(metadata.as_ref()); + for field in fields { + collect_managed_reference_child_text(units, &managed_context, field); + } + } + UnitySerializedValue::ManagedReferenceRegistry { references, fields } => { + if references.is_empty() { + let fallback_metadata = managed_reference_metadata_from_fields(fields); + for field in fields { + let sibling_metadata = + managed_reference_metadata_from_sibling_fields(fields, field); + let managed_context = context.with_managed_reference( + sibling_metadata.as_ref().or(fallback_metadata.as_ref()), + ); + collect_managed_reference_child_text(units, &managed_context, field); + } + } else { + for reference in references { + collect_managed_reference_record_text(units, context, reference); + } + } + } + UnitySerializedValue::Array(values) | UnitySerializedValue::Map(values) => { + for field in values { + collect_field_text(units, context, field); + } + } + _ => {} + } +} + +fn collect_managed_reference_record_text<'a>( + units: &mut Vec, + context: &FieldTextContext<'a>, + reference: &'a UnityManagedReferenceRecord, +) { + let managed_context = context.with_managed_reference(Some(&reference.metadata)); + for field in &reference.fields { + collect_field_text(units, &managed_context, field); + } +} + +fn collect_managed_reference_child_text<'a>( + units: &mut Vec, + context: &FieldTextContext<'a>, + field: &'a UnitySerializedField, +) { + let key = normalized_text_metadata_key(&field.name); + if is_managed_reference_metadata_text_key(&key) { + return; + } + if is_managed_reference_payload_text_key(&key) { + if let Some(children) = serialized_field_children(field) { + for child in children { + collect_field_text(units, context, child); + } + } else { + collect_field_text(units, context, field); + } + return; + } + if let Some(children) = serialized_field_children(field) { + if let Some(metadata) = managed_reference_metadata_from_fields(children) { + let managed_context = context.with_managed_reference(Some(&metadata)); + for child in children { + collect_managed_reference_child_text(units, &managed_context, child); + } + return; + } + } + collect_field_text(units, context, field); +} + +fn managed_reference_metadata_from_sibling_fields( + fields: &[UnitySerializedField], + field: &UnitySerializedField, +) -> Option { + let record_prefix = managed_reference_record_path_prefix(&field.path)?; + let grouped_fields = fields + .iter() + .filter(|candidate| { + candidate.path == record_prefix + || candidate + .path + .strip_prefix(record_prefix) + .is_some_and(|suffix| suffix.starts_with('.')) + }) + .cloned() + .collect::>(); + managed_reference_metadata_from_fields(&grouped_fields) +} + +fn managed_reference_record_path_prefix(path: &str) -> Option<&str> { + if let Some(index_end) = path.rfind(']') { + return Some(&path[..=index_end]); + } + path.rsplit_once('.') + .map(|(parent, _)| parent) + .filter(|parent| !parent.is_empty()) +} + +fn insert_managed_reference_context( + context: &mut BTreeMap, + metadata: &UnityManagedReferenceMetadata, +) { + if let Some(reference_id) = metadata.reference_id { + context.insert("managed_reference_id".to_string(), reference_id.to_string()); + } + if let Some(value) = &metadata.full_type_name { + context.insert( + "managed_reference_full_type_name".to_string(), + value.clone(), + ); + } + if let Some(value) = &metadata.type_name { + context.insert("managed_reference_type".to_string(), value.clone()); + } + if let Some(value) = &metadata.namespace { + context.insert("managed_reference_namespace".to_string(), value.clone()); + } + if let Some(value) = &metadata.assembly_name { + context.insert("managed_reference_assembly".to_string(), value.clone()); + } +} + +fn serialized_field_children(field: &UnitySerializedField) -> Option<&[UnitySerializedField]> { + match &field.value { + UnitySerializedValue::Object(fields) + | UnitySerializedValue::Array(fields) + | UnitySerializedValue::Map(fields) + | UnitySerializedValue::ManagedReference { fields, .. } + | UnitySerializedValue::ManagedReferenceRegistry { fields, .. } => Some(fields), + _ => None, + } +} + +fn normalized_text_metadata_key(name: &str) -> String { + name.strip_prefix("m_") + .unwrap_or(name) + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn is_managed_reference_metadata_text_key(key: &str) -> bool { + matches!( + key, + "rid" + | "id" + | "identifier" + | "refid" + | "referenceid" + | "managedreferenceid" + | "managedreferenceids" + | "managedreferencesid" + | "managedreferencesids" + | "serializedreferenceid" + | "serializedreferenceids" + | "refids" + | "type" + | "typeid" + | "typeinfo" + | "typename" + | "fullname" + | "fulltypename" + | "class" + | "classname" + | "managedreferenceclassname" + | "serializedreferenceclassname" + | "klass" + | "managedtype" + | "managedreferencetype" + | "managedreferencefullname" + | "managedreferencefulltypename" + | "serializedreferencetype" + | "serializedreferencefullname" + | "serializedreferencefulltypename" + | "assemblyqualifiedname" + | "ns" + | "namespace" + | "namespacename" + | "managedreferencenamespace" + | "managedreferencenamespacename" + | "serializedreferencenamespace" + | "serializedreferencenamespacename" + | "asm" + | "asmname" + | "assembly" + | "assemblyname" + | "managedreferenceassembly" + | "managedreferenceassemblyname" + | "serializedreferenceassembly" + | "serializedreferenceassemblyname" + ) +} + +fn is_managed_reference_payload_text_key(key: &str) -> bool { + matches!( + key, + "data" + | "payload" + | "value" + | "object" + | "instance" + | "managedreferencepayload" + | "referencepayload" + | "serializedreferencepayload" + | "managedreferencevalue" + | "referencevalue" + | "serializedreferencevalue" + | "managedreferenceobject" + | "referenceobject" + | "serializedreferenceobject" + | "managedreferencedata" + | "referencedata" + | "serializeddata" + | "serializedreferencedata" + ) +} + +fn decode_text_payload(bytes: &[u8]) -> Option<(&'static str, String)> { + let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes); + if bytes.contains(&0) { + return None; + } + let text = std::str::from_utf8(bytes).ok()?.to_string(); + if text.trim().is_empty() + || text + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return None; + } + + let trimmed = text.trim(); + if serde_json::from_str::(trimmed).is_ok() { + return Some(("json", text)); + } + if text.lines().any(|line| line.contains('\t')) { + return Some(("tsv", text)); + } + if text.lines().count() > 1 && text.lines().any(|line| line.contains(',')) { + return Some(("csv", text)); + } + Some(("plain", text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialized::UnitySerializedTextAsset; + use crate::types::{ + ParsedAssetBundle, UnityFsBlockInfo, UnityFsDirectoryInfo, UnityFsHeader, + UnitySerializedParseError, + }; + + #[test] + fn extracts_textasset_and_writes_jsonl() { + let bundle = ParsedAssetBundle { + unity_version: "2021.3.56f2".to_string(), + assets: vec!["CAB-test".to_string()], + raw_data: Vec::new(), + unityfs_header: Some(UnityFsHeader { + format_version: 8, + target_version: "5.x.x".to_string(), + unity_version: "2021.3.56f2".to_string(), + total_size: 0, + compressed_blocks_info_size: 0, + uncompressed_blocks_info_size: 0, + flags: 0, + }), + blocks: vec![UnityFsBlockInfo { + uncompressed_size: 0, + compressed_size: 0, + flags: 0, + compression: crate::types::UnityFsCompression::None, + }], + directories: vec![UnityFsDirectoryInfo { + offset: 0, + size: 0, + flags: 0, + path: "CAB-test".to_string(), + }], + files: Vec::new(), + serialized_files: Vec::new(), + text_assets: vec![UnitySerializedTextAsset { + source_path: Some("CAB-test".to_string()), + path_id: 1, + name: "dialogue.json".to_string(), + bytes: br#"{"text":"hello"}"#.to_vec(), + }], + serialized_parse_errors: Vec::::new(), + }; + + let report = TextUnitExtractor::new().extract_bundle_with_context( + &bundle, + Some("dialogue.bundle"), + Some("assets/dialogue.bundle"), + ); + + assert_eq!(report.units.len(), 1); + assert_eq!( + report.units[0].archive_entry.as_deref(), + Some("assets/dialogue.bundle") + ); + assert_eq!( + report.units[0].context.get("format"), + Some(&"json".to_string()) + ); + assert_eq!( + text_units_to_jsonl(&report.units).unwrap(), + format!("{}\n", serde_json::to_string(&report.units[0]).unwrap()) + ); + } + + #[test] + fn extracts_managed_reference_payload_without_metadata_strings() { + let payload_field = string_field( + "m_ManagedReferences.references[0].data.message", + "message", + "こんにちは", + ); + let metadata_field = string_field( + "m_ManagedReferences.references[0].managedReferenceFullTypeName", + "managedReferenceFullTypeName", + "Game BA.Text.ScenarioLine", + ); + let registry_field = UnitySerializedField { + path: "m_ManagedReferences".to_string(), + name: "m_ManagedReferences".to_string(), + type_name: "ManagedReferenceRegistry".to_string(), + offset: 0, + byte_size: 64, + type_tree_node_index: None, + value: UnitySerializedValue::ManagedReferenceRegistry { + references: vec![UnityManagedReferenceRecord { + metadata: UnityManagedReferenceMetadata { + reference_id: Some(42), + full_type_name: Some("Game BA.Text.ScenarioLine".to_string()), + type_name: Some("ScenarioLine".to_string()), + namespace: Some("BA.Text".to_string()), + assembly_name: Some("Game".to_string()), + }, + fields: vec![payload_field.clone()], + }], + fields: vec![metadata_field], + }, + }; + let context = FieldTextContext { + serialized_file_path: Some("CAB-test"), + path_id: 7, + class_id: 114, + version: "2021.3.56f2", + bundle_path: Some("scenario.bundle"), + archive_entry: None, + managed_reference: None, + }; + let mut units = Vec::new(); + + collect_field_text(&mut units, &context, ®istry_field); + + assert_eq!(units.len(), 1); + assert_eq!(units[0].source_text, "こんにちは"); + assert_eq!( + units[0].field_path.as_deref(), + Some("m_ManagedReferences.references[0].data.message") + ); + assert_eq!( + units[0].context.get("source_kind"), + Some(&"ManagedReferenceField".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_id"), + Some(&"42".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_full_type_name"), + Some(&"Game BA.Text.ScenarioLine".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_type"), + Some(&"ScenarioLine".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_namespace"), + Some(&"BA.Text".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_assembly"), + Some(&"Game".to_string()) + ); + } + + #[test] + fn extracts_fallback_managed_reference_payload_alias_without_metadata_strings() { + let payload_field = string_field( + "m_ManagedReferences.RefIds[0].managedReferenceData.message", + "message", + "こんにちは", + ); + let registry_field = UnitySerializedField { + path: "m_ManagedReferences".to_string(), + name: "m_ManagedReferences".to_string(), + type_name: "ManagedReferencesRegistry".to_string(), + offset: 0, + byte_size: 96, + type_tree_node_index: None, + value: UnitySerializedValue::ManagedReferenceRegistry { + references: Vec::new(), + fields: vec![ + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceClassName", + "managedReferenceClassName", + "ScenarioLine", + ), + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceNamespaceName", + "managedReferenceNamespaceName", + "BA.Text", + ), + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceAssemblyName", + "managedReferenceAssemblyName", + "Game", + ), + string_field( + "m_ManagedReferences.RefIds[0].serializedReferenceFullTypeName", + "serializedReferenceFullTypeName", + "Game BA.Text.ScenarioLine", + ), + string_field( + "m_ManagedReferences.RefIds[0].typeInfo", + "typeInfo", + "Game BA.Text.ScenarioLine", + ), + string_field( + "m_ManagedReferences.RefIds[0].typeID.className", + "className", + "ScenarioLine", + ), + string_field( + "m_ManagedReferences.RefIds[0].typeID.namespaceName", + "namespaceName", + "BA.Text", + ), + string_field( + "m_ManagedReferences.RefIds[0].typeID.asmName", + "asmName", + "Game", + ), + UnitySerializedField { + path: "m_ManagedReferences.RefIds[0].managedReferenceData".to_string(), + name: "managedReferenceData".to_string(), + type_name: "managedReference".to_string(), + offset: 64, + byte_size: 24, + type_tree_node_index: None, + value: UnitySerializedValue::Object(vec![payload_field]), + }, + ], + }, + }; + let context = FieldTextContext { + serialized_file_path: Some("CAB-test"), + path_id: 7, + class_id: 114, + version: "2021.3.56f2", + bundle_path: Some("scenario.bundle"), + archive_entry: None, + managed_reference: None, + }; + let mut units = Vec::new(); + + collect_field_text(&mut units, &context, ®istry_field); + + assert_eq!(units.len(), 1); + assert_eq!(units[0].source_text, "こんにちは"); + assert_eq!( + units[0].field_path.as_deref(), + Some("m_ManagedReferences.RefIds[0].managedReferenceData.message") + ); + assert_eq!( + units[0].context.get("source_kind"), + Some(&"ManagedReferenceField".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_type"), + Some(&"ScenarioLine".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_namespace"), + Some(&"BA.Text".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_assembly"), + Some(&"Game".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_full_type_name"), + Some(&"Game BA.Text.ScenarioLine".to_string()) + ); + } + + #[test] + fn extracts_fallback_managed_reference_payload_family_alias_with_context() { + let payload_field = string_field( + "m_ManagedReferences.RefIds[0].serializedReferencePayload.message", + "message", + "こんにちは", + ); + let registry_field = UnitySerializedField { + path: "m_ManagedReferences".to_string(), + name: "m_ManagedReferences".to_string(), + type_name: "ManagedReferencesRegistry".to_string(), + offset: 0, + byte_size: 96, + type_tree_node_index: None, + value: UnitySerializedValue::ManagedReferenceRegistry { + references: Vec::new(), + fields: vec![ + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceClassName", + "managedReferenceClassName", + "ScenarioLine", + ), + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceNamespaceName", + "managedReferenceNamespaceName", + "BA.Text", + ), + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceAssemblyName", + "managedReferenceAssemblyName", + "Game", + ), + UnitySerializedField { + path: "m_ManagedReferences.RefIds[0].serializedReferencePayload" + .to_string(), + name: "serializedReferencePayload".to_string(), + type_name: "managedReference".to_string(), + offset: 64, + byte_size: 24, + type_tree_node_index: None, + value: UnitySerializedValue::Object(vec![payload_field]), + }, + ], + }, + }; + let context = FieldTextContext { + serialized_file_path: Some("CAB-test"), + path_id: 7, + class_id: 114, + version: "2021.3.56f2", + bundle_path: Some("scenario.bundle"), + archive_entry: None, + managed_reference: None, + }; + let mut units = Vec::new(); + + collect_field_text(&mut units, &context, ®istry_field); + + assert_eq!(units.len(), 1); + assert_eq!(units[0].source_text, "こんにちは"); + assert_eq!( + units[0].field_path.as_deref(), + Some("m_ManagedReferences.RefIds[0].serializedReferencePayload.message") + ); + assert_eq!( + units[0].context.get("source_kind"), + Some(&"ManagedReferenceField".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_type"), + Some(&"ScenarioLine".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_namespace"), + Some(&"BA.Text".to_string()) + ); + assert_eq!( + units[0].context.get("managed_reference_assembly"), + Some(&"Game".to_string()) + ); + } + + #[test] + fn extracts_fallback_managed_reference_sibling_records_with_separate_context() { + let registry_field = UnitySerializedField { + path: "m_ManagedReferences".to_string(), + name: "m_ManagedReferences".to_string(), + type_name: "ManagedReferencesRegistry".to_string(), + offset: 0, + byte_size: 160, + type_tree_node_index: None, + value: UnitySerializedValue::ManagedReferenceRegistry { + references: Vec::new(), + fields: vec![ + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceClassName", + "managedReferenceClassName", + "ScenarioLine", + ), + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceNamespaceName", + "managedReferenceNamespaceName", + "BA.Text", + ), + string_field( + "m_ManagedReferences.RefIds[0].managedReferenceAssemblyName", + "managedReferenceAssemblyName", + "Game", + ), + UnitySerializedField { + path: "m_ManagedReferences.RefIds[0].managedReferenceData".to_string(), + name: "managedReferenceData".to_string(), + type_name: "managedReference".to_string(), + offset: 64, + byte_size: 24, + type_tree_node_index: None, + value: UnitySerializedValue::Object(vec![string_field( + "m_ManagedReferences.RefIds[0].managedReferenceData.message", + "message", + "こんにちは", + )]), + }, + string_field( + "m_ManagedReferences.RefIds[1].managedReferenceClassName", + "managedReferenceClassName", + "ChoiceLine", + ), + string_field( + "m_ManagedReferences.RefIds[1].managedReferenceNamespaceName", + "managedReferenceNamespaceName", + "BA.Text", + ), + string_field( + "m_ManagedReferences.RefIds[1].managedReferenceAssemblyName", + "managedReferenceAssemblyName", + "Game", + ), + UnitySerializedField { + path: "m_ManagedReferences.RefIds[1].referencePayload".to_string(), + name: "referencePayload".to_string(), + type_name: "managedReference".to_string(), + offset: 120, + byte_size: 24, + type_tree_node_index: None, + value: UnitySerializedValue::Object(vec![string_field( + "m_ManagedReferences.RefIds[1].referencePayload.message", + "message", + "選択肢", + )]), + }, + ], + }, + }; + let context = FieldTextContext { + serialized_file_path: Some("CAB-test"), + path_id: 7, + class_id: 114, + version: "2021.3.56f2", + bundle_path: Some("scenario.bundle"), + archive_entry: None, + managed_reference: None, + }; + let mut units = Vec::new(); + + collect_field_text(&mut units, &context, ®istry_field); + + assert_eq!(units.len(), 2); + assert_eq!( + units[0].field_path.as_deref(), + Some("m_ManagedReferences.RefIds[0].managedReferenceData.message") + ); + assert_eq!(units[0].source_text, "こんにちは"); + assert_eq!( + units[0].context.get("managed_reference_type"), + Some(&"ScenarioLine".to_string()) + ); + assert_eq!( + units[1].field_path.as_deref(), + Some("m_ManagedReferences.RefIds[1].referencePayload.message") + ); + assert_eq!(units[1].source_text, "選択肢"); + assert_eq!( + units[1].context.get("managed_reference_type"), + Some(&"ChoiceLine".to_string()) + ); + } + + fn string_field(path: &str, name: &str, value: &str) -> UnitySerializedField { + UnitySerializedField { + path: path.to_string(), + name: name.to_string(), + type_name: "string".to_string(), + offset: 0, + byte_size: value.len() + 4, + type_tree_node_index: None, + value: UnitySerializedValue::String(value.to_string()), + } + } +} diff --git a/infrastructure/Cargo.toml b/infrastructure/Cargo.toml index cc2aa64..acd0c22 100644 --- a/infrastructure/Cargo.toml +++ b/infrastructure/Cargo.toml @@ -14,6 +14,7 @@ bat-core = { path = "../core" } bat-adapters = { path = "../adapters" } bat-assetbundle = { path = "../crates/bat-assetbundle" } bat-cas-engine = { path = "../crates/bat-cas-engine" } +bat-patch = { path = "../crates/bat-patch" } anyhow.workspace = true thiserror.workspace = true serde.workspace = true diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 64b3ce1..1a66faf 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,15 +1,26 @@ -use bat_adapters::official::yostar_jp::PatchPlatform; +use bat_adapters::official::yostar_jp::{PatchPlatform, YostarJpResourceEndpointKind}; +use bat_assetbundle::UnitySerializedReplacementValue; +use bat_core::domain::{Resource, ResourceType}; +use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; use bat_core::{ApiError, ErrorCode}; use bat_infrastructure::{ - changed_endpoint_urls, diff_extended_snapshot, gc_orphan_staging, lexical_absolute, - open_append_file, read_download_manifest_at, read_file_no_symlink, read_snapshot, - read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root, - validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode, - OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialServerInfoSource, + apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, + apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot, + gc_orphan_staging, lexical_absolute, open_append_file, read_download_manifest_at, + read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, + read_parse_cache_at, read_snapshot, read_textunit_index_at, read_version_state, + redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, + write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialEndpointMarkerRole, + OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialTextUnitQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, - OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, + OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams, + PatchApplyReport, SqliteResourceRepository, UnityFsFieldPatchParams, UnityFsPatchReport, + UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, + LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, + OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE, }; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; @@ -157,6 +168,21 @@ fn run() -> anyhow::Result { run_sync_command(&options, "repair")?; Ok(0) } + CliCommand::ParseStatus + | CliCommand::ParseTextUnits + | CliCommand::ParseErrors + | CliCommand::LocalizedStatus + | CliCommand::ResourceIndex => { + run_readonly_query_command(&options)?; + Ok(0) + } + CliCommand::PatchApply + | CliCommand::UnityFsPatchTextAsset + | CliCommand::UnityFsPatchStringField + | CliCommand::UnityFsPatchField => { + run_write_patch_command(&options)?; + Ok(0) + } CliCommand::Logs => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; run_logs_command(&options)?; @@ -201,6 +227,33 @@ struct CliOptions { progress: bool, banner: bool, tail_lines: usize, + query_offset: usize, + query_limit: usize, + query_resource_type: Option, + query_hash: Option, + query_path_pattern: Option, + query_destination: Option, + query_archive_entry: Option, + query_path_id: Option, + query_class_id: Option, + query_field_path: Option, + query_format: Option, + query_option_explicit: bool, + patch_kind: Option, + patch_source_path: Option, + patch_patch_path: Option, + patch_target_path: Option, + unityfs_bundle_path: Option, + unityfs_serialized_file_path: Option, + unityfs_path_id: Option, + unityfs_field_path: Option, + unityfs_replacement_path: Option, + unityfs_replacement_text: Option, + unityfs_expected_name: Option, + unityfs_expected_value: Option, + unityfs_replacement_value: Option, + unityfs_expected_semantic_value: Option, + write_patch_option_explicit: bool, /// 环境变量(含 .env)应用后、命令行解析前的配置快照。 /// 工具/代理"是否命令行显式传入"的判断以它为基线。 env_baseline_config: OfficialUpdateConfig, @@ -226,6 +279,33 @@ impl Default for CliOptions { progress: true, banner: true, tail_lines: 200, + query_offset: 0, + query_limit: 100, + query_resource_type: None, + query_hash: None, + query_path_pattern: None, + query_destination: None, + query_archive_entry: None, + query_path_id: None, + query_class_id: None, + query_field_path: None, + query_format: None, + query_option_explicit: false, + patch_kind: None, + patch_source_path: None, + patch_patch_path: None, + patch_target_path: None, + unityfs_bundle_path: None, + unityfs_serialized_file_path: None, + unityfs_path_id: None, + unityfs_field_path: None, + unityfs_replacement_path: None, + unityfs_replacement_text: None, + unityfs_expected_name: None, + unityfs_expected_value: None, + unityfs_replacement_value: None, + unityfs_expected_semantic_value: None, + write_patch_option_explicit: false, env_baseline_config: OfficialUpdateConfig::default(), } } @@ -247,6 +327,15 @@ enum CliCommand { Refresh, Verify, Repair, + ParseStatus, + ParseTextUnits, + ParseErrors, + LocalizedStatus, + ResourceIndex, + PatchApply, + UnityFsPatchTextAsset, + UnityFsPatchStringField, + UnityFsPatchField, Doctor, Logs, CleanStable, @@ -394,6 +483,11 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { if should_print_status(report.update_status, options.quiet_up_to_date) { print_report(options.output_format, &report)?; } + let waiting_for_official_resources = + report.update_status == OfficialUpdateStatus::WaitingForOfficialResources; + if waiting_for_official_resources { + sleep_for = options.error_retry_interval; + } sleep_for = sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now())); record_daemon_status( @@ -401,25 +495,42 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { &daemon_state_dir, &mut logger, DaemonStatusUpdate { - state: "sleeping", + state: if waiting_for_official_resources { + "waiting" + } else { + "sleeping" + }, last_update_status: Some(report.update_status.as_str().to_string()), last_error: None, next_retry_seconds: Some(sleep_for.as_secs()), - last_success_unix_seconds: Some(unix_seconds_now()), + last_success_unix_seconds: (!waiting_for_official_resources) + .then(unix_seconds_now), next_check_unix_seconds: Some(unix_seconds_after(sleep_for)), pending_scheduled_force, next_forced_refresh_at, }, ); - logger.log_text( - "watch", - format!( - "本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}", - report.update_status.as_str(), - format_duration(sleep_for), - format_duration(duration_until(next_forced_refresh_at, SystemTime::now())) - ), - ); + if waiting_for_official_resources { + logger.log_text( + "watch", + format!( + "本轮等待官方资源端开放:状态={};将在 {} 后重试;距离下一次固定强制刷新还有 {}", + report.update_status.as_str(), + format_duration(sleep_for), + format_duration(duration_until(next_forced_refresh_at, SystemTime::now())) + ), + ); + } else { + logger.log_text( + "watch", + format!( + "本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}", + report.update_status.as_str(), + format_duration(sleep_for), + format_duration(duration_until(next_forced_refresh_at, SystemTime::now())) + ), + ); + } } Err(error) => { sleep_for = options.error_retry_interval; @@ -517,6 +628,8 @@ struct DaemonStatusFile { pid: u32, state: String, resource_output_root: PathBuf, + #[serde(default)] + localized_output_root: Option, state_dir: PathBuf, log_path: PathBuf, #[serde(default)] @@ -652,7 +765,12 @@ const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync"; const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify"; const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair"; const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest"; +const RPC_METHOD_RESOURCE_INDEX: &str = "resource.index"; const RPC_METHOD_RESOURCE_LIST: &str = "resource.list"; +const RPC_METHOD_PARSE_STATUS: &str = "parse.status"; +const RPC_METHOD_PARSE_TEXT_UNITS: &str = "parse.text_units"; +const RPC_METHOD_PARSE_ERRORS: &str = "parse.errors"; +const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; @@ -661,6 +779,10 @@ const RPC_METHOD_TASK_STATUS: &str = "task.status"; const RPC_METHOD_TASK_LIST: &str = "task.list"; const RPC_METHOD_TASK_CANCEL: &str = "task.cancel"; const RPC_METHOD_TASK_LOGS: &str = "task.logs"; +const RPC_METHOD_PATCH_APPLY: &str = "patch.apply"; +const RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET: &str = "unityfs.patch_text_asset"; +const RPC_METHOD_UNITYFS_PATCH_STRING_FIELD: &str = "unityfs.patch_string_field"; +const RPC_METHOD_UNITYFS_PATCH_FIELD: &str = "unityfs.patch_field"; /// 保留的已完成任务上限(内存态,超出后裁剪最旧的已结束任务)。 const MAX_RETAINED_TASKS: usize = 64; @@ -1264,12 +1386,16 @@ fn is_pending_rpc_method(method: &str) -> bool { // 等语义方法创建,通用创建接口暂不开放。 // daemon.restart / daemon.clean-stable:CLI 侧按进程生命周期处理; // live RPC 内不做自重启或在线清理。 - // patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。 + // patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂 + // UnityFS 语义编辑和 inspect 等子命令仍未开放。 matches!( method, "task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE - ) || method.starts_with("patch.") - || method.starts_with("unityfs.") + ) || (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY) + || (method.starts_with("unityfs.") + && method != RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET + && method != RPC_METHOD_UNITYFS_PATCH_STRING_FIELD + && method != RPC_METHOD_UNITYFS_PATCH_FIELD) } /// RPC 应用层统一 envelope,装入 JSON-RPC 2.0 的 `result`。 @@ -1929,6 +2055,76 @@ fn dispatch_rpc_method( build_resource_manifest_report(state_dir, offset, limit), ) } + RPC_METHOD_RESOURCE_INDEX => { + let (query, offset, limit) = match rpc_resource_index_params(request.params.as_ref()) { + Ok(params) => params, + Err(error) => { + return rpc_envelope_error( + request_id, + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + "resource.index", + error.to_string(), + ), + ) + } + }; + rpc_envelope_from_result( + request_id, + "resource.index", + build_resource_index_report(state_dir, &tasks.base_config, query, offset, limit), + ) + } + RPC_METHOD_PARSE_STATUS => rpc_envelope_from_result( + request_id, + "parse.status", + build_parse_status_report(state_dir), + ), + RPC_METHOD_PARSE_TEXT_UNITS => { + let (query, offset, limit) = match rpc_textunit_query_params(request.params.as_ref()) { + Ok(params) => params, + Err(error) => { + return rpc_envelope_error( + request_id, + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + "parse.text_units", + error.to_string(), + ), + ) + } + }; + rpc_envelope_from_result( + request_id, + "parse.text_units", + build_parse_text_units_report(state_dir, query, offset, limit), + ) + } + RPC_METHOD_PARSE_ERRORS => { + let (query, offset, limit) = match rpc_textunit_query_params(request.params.as_ref()) { + Ok(params) => params, + Err(error) => { + return rpc_envelope_error( + request_id, + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + "parse.errors", + error.to_string(), + ), + ) + } + }; + rpc_envelope_from_result( + request_id, + "parse.errors", + build_parse_errors_report(state_dir, query, offset, limit), + ) + } + RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result( + request_id, + "localized.status", + build_localized_status_report(state_dir, &tasks.base_config), + ), RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result( request_id, "catalog.status", @@ -2014,6 +2210,66 @@ fn dispatch_rpc_method( ), } } + RPC_METHOD_PATCH_APPLY => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_PATCH_APPLY, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_PATCH_APPLY, + apply_patch_file(¶ms) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } + RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET, + apply_unityfs_text_asset_patch_file(¶ms) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } + RPC_METHOD_UNITYFS_PATCH_STRING_FIELD => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_UNITYFS_PATCH_STRING_FIELD, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_UNITYFS_PATCH_STRING_FIELD, + apply_unityfs_string_field_patch_file(¶ms) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } + RPC_METHOD_UNITYFS_PATCH_FIELD => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_UNITYFS_PATCH_FIELD, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_UNITYFS_PATCH_FIELD, + apply_unityfs_field_patch_file(¶ms) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } pending if is_pending_rpc_method(pending) => rpc_envelope_error( request_id, ApiError::new( @@ -2258,6 +2514,354 @@ fn build_resource_manifest_report( })) } +/// `resource.index`:当前 ResourceRepository 的分页/过滤查询。 +fn build_resource_index_report( + state_dir: &Path, + base_config: &OfficialUpdateConfig, + query: ResourceQuery, + offset: usize, + limit: usize, +) -> anyhow::Result { + let (status_file, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let repository_path = resource_repository_path_for_status(status_file.as_ref(), base_config); + if !repository_file_exists_no_symlink(&repository_path)? { + return Ok(serde_json::json!({ + "available": false, + "current_version_id": current.map(|record| record.id.clone()), + "repository_path": repository_path, + "import_enabled": base_config.import_repository, + })); + } + let Some(record) = current else { + return Ok(serde_json::json!({ + "available": false, + "repository_path": repository_path, + "import_enabled": base_config.import_repository, + })); + }; + + let (total_entries, entries) = + query_resource_repository(&repository_path, query.clone(), offset, limit)?; + Ok(serde_json::json!({ + "available": true, + "current_version_id": record.id, + "resource_root": record.resource_root, + "repository_path": repository_path, + "import_enabled": base_config.import_repository, + "total_entries": total_entries, + "offset": offset, + "limit": limit, + "query": { + "resource_type": query.resource_type, + "hash": query.hash, + "path_pattern": query.path_pattern, + }, + "entries": entries, + })) +} + +fn resource_repository_path_for_status( + status_file: Option<&DaemonStatusFile>, + base_config: &OfficialUpdateConfig, +) -> PathBuf { + base_config + .import_resource_repository_path + .clone() + .or_else(|| status_file.map(|status| status.resource_output_root.join("resources.sqlite"))) + .unwrap_or_else(|| base_config.effective_import_resource_repository_path()) +} + +fn query_resource_repository( + repository_path: &Path, + query: ResourceQuery, + offset: usize, + limit: usize, +) -> anyhow::Result<(u64, Vec)> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let repository = SqliteResourceRepository::new(repository_path) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let total_entries = repository + .count(query.clone()) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let entries = repository + .list(query) + .await + .map_err(|error| anyhow::anyhow!("{error}"))? + .into_iter() + .skip(offset) + .take(limit) + .collect(); + Ok((total_entries, entries)) + }) +} + +fn repository_file_exists_no_symlink(path: &Path) -> anyhow::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!( + "资源索引数据库不能是 symlink:{}", + path.display() + )), + Ok(metadata) if metadata.is_file() => Ok(true), + Ok(_) => Err(anyhow::anyhow!( + "资源索引数据库不是普通文件:{}", + path.display() + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +/// `parse.status`:当前已发布版本的解析缓存摘要。 +fn build_parse_status_report(state_dir: &Path) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let Some(record) = current else { + return Ok(serde_json::json!({ "available": false })); + }; + let cache_path = record.resource_root.join(OFFICIAL_PARSE_CACHE_FILE); + let Some(cache) = read_parse_cache_at(&record.resource_root).map_err(anyhow::Error::msg)? + else { + return Ok(serde_json::json!({ + "available": false, + "current_version_id": record.id, + "resource_root": record.resource_root, + "cache_path": cache_path, + })); + }; + let textunit_queue_path = record + .resource_root + .join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + let textunit_queue = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root) + .map_err(anyhow::Error::msg)?; + let textunit_index_path = record.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE); + let textunit_index = + read_textunit_index_at(&record.resource_root).map_err(anyhow::Error::msg)?; + Ok(serde_json::json!({ + "available": true, + "current_version_id": record.id, + "resource_root": record.resource_root, + "cache_path": cache_path, + "cache_version": cache.version, + "generated_unix_seconds": cache.generated_unix_seconds, + "summary": cache.summary, + "textunit_queue_available": textunit_queue.is_some(), + "textunit_task_queue_path": textunit_queue_path, + "textunit_task_summary": textunit_queue.map(|queue| queue.summary), + "textunit_index_available": textunit_index.is_some(), + "textunit_index_path": textunit_index_path, + "textunit_index_summary": textunit_index.map(|index| index.summary), + })) +} + +/// `parse.text_units`:当前已发布版本的 TextUnit 明细查询。 +fn build_parse_text_units_report( + state_dir: &Path, + query: OfficialTextUnitQuery, + offset: usize, + limit: usize, +) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let Some(record) = current else { + return Ok(serde_json::json!({ "available": false })); + }; + let index_path = record.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE); + let Some(index) = read_textunit_index_at(&record.resource_root).map_err(anyhow::Error::msg)? + else { + return Ok(serde_json::json!({ + "available": false, + "current_version_id": record.id, + "resource_root": record.resource_root, + "textunit_index_path": index_path, + })); + }; + let matches = bat_infrastructure::query_textunit_index_units(&index, &query); + let total_entries = matches.len(); + let entries = matches + .into_iter() + .skip(offset) + .take(limit) + .cloned() + .collect::>(); + Ok(serde_json::json!({ + "available": true, + "current_version_id": record.id, + "resource_root": record.resource_root, + "textunit_index_path": index_path, + "summary": index.summary, + "total_entries": total_entries, + "offset": offset, + "limit": limit, + "query": textunit_query_json(&query), + "entries": entries, + })) +} + +/// `parse.errors`:当前已发布版本的解析/提取诊断查询。 +fn build_parse_errors_report( + state_dir: &Path, + query: OfficialTextUnitQuery, + offset: usize, + limit: usize, +) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let Some(record) = current else { + return Ok(serde_json::json!({ "available": false })); + }; + let index_path = record.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE); + let Some(index) = read_textunit_index_at(&record.resource_root).map_err(anyhow::Error::msg)? + else { + return Ok(serde_json::json!({ + "available": false, + "current_version_id": record.id, + "resource_root": record.resource_root, + "textunit_index_path": index_path, + })); + }; + let matches = bat_infrastructure::query_textunit_index_errors(&index, &query); + let total_entries = matches.len(); + let entries = matches + .into_iter() + .skip(offset) + .take(limit) + .cloned() + .collect::>(); + Ok(serde_json::json!({ + "available": true, + "current_version_id": record.id, + "resource_root": record.resource_root, + "textunit_index_path": index_path, + "summary": index.summary, + "total_entries": total_entries, + "offset": offset, + "limit": limit, + "query": textunit_query_json(&query), + "entries": entries, + })) +} + +fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value { + serde_json::json!({ + "destination": query.destination.clone(), + "path_pattern": query.path_pattern.clone(), + "archive_entry": query.archive_entry.clone(), + "path_id": query.path_id, + "class_id": query.class_id, + "field_path": query.field_path.clone(), + "format": query.format.clone(), + }) +} + +/// `localized.status`:当前官方版本对应的汉化 release 状态。 +fn build_localized_status_report( + state_dir: &Path, + base_config: &OfficialUpdateConfig, +) -> anyhow::Result { + let (status_file, version_state) = read_daemon_resource_state(state_dir)?; + let official_version_id = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + .map(|record| record.id.clone()); + let localized_root = status_file + .as_ref() + .and_then(|status| status.localized_output_root.clone()) + .unwrap_or_else(|| base_config.localized_output_root.clone()); + let state_path = localized_root.join(LOCALIZED_VERSION_STATE_FILE); + let current_path = localized_root.join(LOCALIZED_CURRENT_LINK); + let state = read_localized_version_state(&localized_root)?; + let mut status = "not_localized"; + let mut published_version_path = None; + let mut matches_current_official_release = false; + let mut current_points_to_published_version = false; + let mut patch_manifest_path = None; + let mut patch_manifest_available = false; + let mut patch_manifest_matches_release = false; + let mut patch_file_count = None; + let mut patch_text_asset_operation_count = None; + let mut rollback_previous_current_target = None; + + if let Some(localized_state) = state.as_ref() { + matches_current_official_release = official_version_id + .as_deref() + .is_some_and(|id| localized_state.official_release_id == id); + if localized_state.status == "localized" && matches_current_official_release { + if let Some(release_id) = localized_state.current_release_id.as_deref() { + let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id); + current_points_to_published_version = + localized_current_points_to(¤t_path, &candidate); + let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE); + patch_manifest_path = Some(manifest_path); + if let Some(manifest) = read_localized_patch_manifest_at(&candidate)? { + patch_manifest_available = true; + patch_manifest_matches_release = official_version_id + .as_deref() + .is_some_and(|id| manifest.official_release_id == id) + && manifest.localized_release_id == release_id; + patch_file_count = Some(manifest.file_count); + patch_text_asset_operation_count = Some(manifest.text_asset_operation_count); + rollback_previous_current_target = manifest.rollback.previous_current_target; + } + if candidate.is_dir() + && current_points_to_published_version + && patch_manifest_matches_release + { + status = "localized"; + published_version_path = Some(candidate); + } + } + } + } + + Ok(serde_json::json!({ + "available": state.is_some(), + "status": status, + "official_current_version_id": official_version_id, + "localized_output_root": localized_root, + "state_path": state_path, + "current_path": current_path, + "published_version_path": published_version_path, + "patch_manifest_path": patch_manifest_path, + "patch_manifest_available": patch_manifest_available, + "patch_manifest_matches_release": patch_manifest_matches_release, + "patch_file_count": patch_file_count, + "patch_text_asset_operation_count": patch_text_asset_operation_count, + "rollback_previous_current_target": rollback_previous_current_target, + "matches_current_official_release": matches_current_official_release, + "current_points_to_published_version": current_points_to_published_version, + "state": state, + })) +} + +fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool { + let Ok(target) = fs::read_link(current_path) else { + return false; + }; + let resolved = if target.is_absolute() { + target + } else { + current_path + .parent() + .map(|parent| parent.join(&target)) + .unwrap_or(target) + }; + resolved == version_path +} + #[cfg(unix)] fn write_json_rpc_response( stream: &mut UnixStream, @@ -2302,6 +2906,26 @@ fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option .and_then(serde_json::Value::as_bool) } +fn rpc_struct_params( + params: Option<&serde_json::Value>, + method: &'static str, +) -> Result { + let Some(params) = params else { + return Err(ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + method, + "params 不能为空", + )); + }; + serde_json::from_value(params.clone()).map_err(|error| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + method, + format!("params 无效:{error}"), + ) + }) +} + /// 分页参数:`offset` 默认 0;`limit` 默认 100,范围 1..=1000。 fn rpc_page_params(params: Option<&serde_json::Value>) -> anyhow::Result<(usize, usize)> { let offset = params @@ -2320,6 +2944,115 @@ fn rpc_page_params(params: Option<&serde_json::Value>) -> anyhow::Result<(usize, Ok((offset, limit)) } +fn rpc_resource_index_params( + params: Option<&serde_json::Value>, +) -> anyhow::Result<(ResourceQuery, usize, usize)> { + let (offset, limit) = rpc_page_params(params)?; + let resource_type = rpc_string_param(params, "resource_type") + .or_else(|| rpc_string_param(params, "type")) + .map(parse_resource_type_param) + .transpose()?; + let query = ResourceQuery { + resource_type, + hash: rpc_string_param(params, "hash").map(str::to_string), + path_pattern: rpc_string_param(params, "path_pattern").map(str::to_string), + }; + Ok((query, offset, limit)) +} + +fn rpc_textunit_query_params( + params: Option<&serde_json::Value>, +) -> anyhow::Result<(OfficialTextUnitQuery, usize, usize)> { + let (offset, limit) = rpc_page_params(params)?; + let query = OfficialTextUnitQuery { + destination: rpc_string_param(params, "destination").map(str::to_string), + path_pattern: rpc_string_param(params, "path_pattern").map(str::to_string), + archive_entry: rpc_string_param(params, "archive_entry").map(str::to_string), + path_id: rpc_i64_param(params, "path_id")?, + class_id: rpc_i32_param(params, "class_id")?, + field_path: rpc_string_param(params, "field_path").map(str::to_string), + format: rpc_string_param(params, "format").map(str::to_string), + }; + Ok((query, offset, limit)) +} + +fn rpc_string_param<'a>(params: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> { + params + .and_then(|params| params.get(key)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn rpc_i64_param(params: Option<&serde_json::Value>, key: &str) -> anyhow::Result> { + let Some(value) = params.and_then(|params| params.get(key)) else { + return Ok(None); + }; + if let Some(number) = value.as_i64() { + return Ok(Some(number)); + } + if let Some(text) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return text + .parse::() + .map(Some) + .map_err(|error| anyhow::anyhow!("{key} 无效:{error}")); + } + Err(anyhow::anyhow!("{key} 必须是整数")) +} + +fn rpc_i32_param(params: Option<&serde_json::Value>, key: &str) -> anyhow::Result> { + let Some(value) = rpc_i64_param(params, key)? else { + return Ok(None); + }; + Ok(Some( + i32::try_from(value).map_err(|_| anyhow::anyhow!("{key} 超出 i32 范围"))?, + )) +} + +fn parse_resource_type_param(value: &str) -> anyhow::Result { + let normalized = value + .chars() + .filter(|ch| !matches!(ch, '_' | '-' | ' ')) + .collect::() + .to_ascii_lowercase(); + match normalized.as_str() { + "assetbundle" => Ok(ResourceType::AssetBundle), + "manifest" => Ok(ResourceType::Manifest), + "tablebundle" => Ok(ResourceType::TableBundle), + "textasset" => Ok(ResourceType::TextAsset), + "media" => Ok(ResourceType::Media), + "other" => Ok(ResourceType::Other), + _ => Err(anyhow::anyhow!("不支持的 resource_type:{value}")), + } +} + +fn parse_patch_apply_kind(value: &str) -> anyhow::Result { + let normalized = value + .chars() + .filter(|ch| !matches!(ch, '_' | '-' | ' ')) + .collect::() + .to_ascii_lowercase(); + match normalized.as_str() { + "binary" => Ok(PatchApplyKind::Binary), + "json" => Ok(PatchApplyKind::Json), + "text" => Ok(PatchApplyKind::Text), + _ => Err(anyhow::anyhow!( + "不支持的 patch kind:{value},可用值为 binary/json/text" + )), + } +} + +fn parse_replacement_value_json( + value: &str, + flag: &str, +) -> anyhow::Result { + serde_json::from_str(value).map_err(|error| anyhow::anyhow!("{flag} JSON 无效:{error}")) +} + fn rpc_tail_param(params: Option<&serde_json::Value>, default: usize) -> anyhow::Result { let tail = params .and_then(|params| params.get("tail")) @@ -2459,6 +3192,7 @@ struct DaemonStartReport { message: &'static str, pid: u32, resource_output_root: PathBuf, + localized_output_root: PathBuf, state_dir: PathBuf, pid_path: PathBuf, status_path: PathBuf, @@ -2474,6 +3208,7 @@ struct DaemonStatusReport { running: bool, pid: Option, resource_output_root: Option, + localized_output_root: Option, state_dir: PathBuf, pid_path: PathBuf, status_path: PathBuf, @@ -2530,12 +3265,14 @@ fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> { fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result { let resource_output_root = normalized_abs_path(&options.config.output_root)?; + let localized_output_root = normalized_abs_path(&options.config.localized_output_root)?; let state_dir = options.state_dir.clone(); let args = daemon_child_args(options); let proxy_url = curl_proxy_url(&options.config.curl_proxy); start_daemon_with_args( state_dir, resource_output_root, + localized_output_root, args, proxy_url, "后台同步已启动", @@ -2545,6 +3282,7 @@ fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result, proxy_url: Option, message: &'static str, @@ -2626,6 +3364,7 @@ fn start_daemon_with_args( pid, state: "started".to_string(), resource_output_root: resource_output_root.clone(), + localized_output_root: Some(localized_output_root.clone()), state_dir: state_dir.clone(), log_path: log_path.clone(), structured_log_path: Some(structured_log_path.clone()), @@ -2650,6 +3389,7 @@ fn start_daemon_with_args( message, pid, resource_output_root, + localized_output_root, state_dir, pid_path, status_path, @@ -2824,6 +3564,9 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result anyho let _ = stop_daemon_inner(&options.state_dir)?; } - let (state_dir, resource_output_root, args, proxy_url, strategy) = if has_explicit_options { - let mut start_options = options.clone(); - start_options.daemon = true; - start_options.watch = false; - ( - options.state_dir.clone(), - normalized_abs_path(&options.config.output_root)?, - daemon_child_args(&start_options), - curl_proxy_url(&options.config.curl_proxy), - "start_with_explicit_options", - ) - } else { - let status_file = status_file.ok_or_else(|| { + let (state_dir, resource_output_root, localized_output_root, args, proxy_url, strategy) = + if has_explicit_options { + let mut start_options = options.clone(); + start_options.daemon = true; + start_options.watch = false; + ( + options.state_dir.clone(), + normalized_abs_path(&options.config.output_root)?, + normalized_abs_path(&options.config.localized_output_root)?, + daemon_child_args(&start_options), + curl_proxy_url(&options.config.curl_proxy), + "start_with_explicit_options", + ) + } else { + let status_file = status_file.ok_or_else(|| { anyhow::anyhow!( "没有可复用的后台配置;请先执行 bat --auto-discover --daemon,或为 {command_name} 显式传入同步参数" ) })?; - let mut command = status_file.command.into_iter(); - let _executable = command.next().ok_or_else(|| { - anyhow::anyhow!("后台状态文件中的 command 为空,无法执行 {command_name}") - })?; - let args = command.collect::>(); - if args.is_empty() { - return Err(anyhow::anyhow!( - "后台状态文件中的 command 参数为空,无法执行 {command_name}" - )); - } - // 复用命令若声明代理从环境变量读取,则必须能从凭据文件还原 URL。 - let proxy_url = if args.iter().any(|arg| arg == PROXY_FROM_ENV_FLAG) { - Some(saved_proxy_url.ok_or_else(|| { + let mut command = status_file.command.into_iter(); + let _executable = command.next().ok_or_else(|| { + anyhow::anyhow!("后台状态文件中的 command 为空,无法执行 {command_name}") + })?; + let args = command.collect::>(); + if args.is_empty() { + return Err(anyhow::anyhow!( + "后台状态文件中的 command 参数为空,无法执行 {command_name}" + )); + } + // 复用命令若声明代理从环境变量读取,则必须能从凭据文件还原 URL。 + let proxy_url = if args.iter().any(|arg| arg == PROXY_FROM_ENV_FLAG) { + Some(saved_proxy_url.ok_or_else(|| { anyhow::anyhow!( "后台配置需要代理凭据但 {DAEMON_PROXY_SECRET_FILE} 缺失;请为 {command_name} 重新传入 --proxy" ) })?) - } else { - None + } else { + None + }; + ( + options.state_dir.clone(), + status_file.resource_output_root, + status_file + .localized_output_root + .unwrap_or_else(|| options.config.localized_output_root.clone()), + args, + proxy_url, + "restart_with_existing_command", + ) }; - ( - options.state_dir.clone(), - status_file.resource_output_root, - args, - proxy_url, - "restart_with_existing_command", - ) - }; let start = start_daemon_with_args( state_dir.clone(), resource_output_root.clone(), + localized_output_root.clone(), args, proxy_url, if command_name == "reload" { @@ -3091,6 +3840,403 @@ fn run_sync_command_foreground( Ok(()) } +fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> { + run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call) +} + +fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> { + match options.command { + CliCommand::PatchApply => { + let params = patch_apply_params_from_options(options)?; + let report = apply_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + CliCommand::UnityFsPatchTextAsset => { + let params = unityfs_text_asset_params_from_options(options)?; + let report = apply_unityfs_text_asset_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + CliCommand::UnityFsPatchStringField => { + let params = unityfs_string_field_params_from_options(options)?; + let report = apply_unityfs_string_field_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + CliCommand::UnityFsPatchField => { + let params = unityfs_field_params_from_options(options)?; + let report = apply_unityfs_field_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + _ => Err(anyhow::anyhow!("不是写入 patch 命令")), + } +} + +fn run_readonly_query_command_with_rpc( + options: &CliOptions, + rpc_available: impl Fn(&Path) -> bool, + rpc_call: impl Fn(&Path, &str, Option) -> anyhow::Result, +) -> anyhow::Result<()> { + let method = readonly_query_rpc_method(options.command) + .ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?; + if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) { + let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; + let report = rpc_call( + &options.state_dir, + method, + readonly_query_rpc_params(options), + )?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let report = build_readonly_query_report(options, method)?; + print_json_value(options.output_format, &report) +} + +fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> { + match command { + CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS), + CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS), + CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS), + CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS), + CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX), + _ => None, + } +} + +fn readonly_query_rpc_params(options: &CliOptions) -> Option { + let mut params = serde_json::Map::new(); + match options.command { + CliCommand::ResourceIndex | CliCommand::ParseTextUnits | CliCommand::ParseErrors => { + params.insert( + "offset".to_string(), + serde_json::json!(options.query_offset), + ); + params.insert("limit".to_string(), serde_json::json!(options.query_limit)); + } + _ => return None, + } + match options.command { + CliCommand::ResourceIndex => { + if let Some(resource_type) = options.query_resource_type { + params.insert( + "resource_type".to_string(), + serde_json::json!(resource_type_rpc_label(resource_type)), + ); + } + if let Some(hash) = options.query_hash.as_ref() { + params.insert("hash".to_string(), serde_json::json!(hash)); + } + if let Some(path_pattern) = options.query_path_pattern.as_ref() { + params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); + } + } + CliCommand::ParseTextUnits | CliCommand::ParseErrors => { + if let Some(destination) = options.query_destination.as_ref() { + params.insert("destination".to_string(), serde_json::json!(destination)); + } + if let Some(path_pattern) = options.query_path_pattern.as_ref() { + params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); + } + if let Some(archive_entry) = options.query_archive_entry.as_ref() { + params.insert( + "archive_entry".to_string(), + serde_json::json!(archive_entry), + ); + } + if let Some(path_id) = options.query_path_id { + params.insert("path_id".to_string(), serde_json::json!(path_id)); + } + if let Some(class_id) = options.query_class_id { + params.insert("class_id".to_string(), serde_json::json!(class_id)); + } + if let Some(field_path) = options.query_field_path.as_ref() { + params.insert("field_path".to_string(), serde_json::json!(field_path)); + } + if let Some(format) = options.query_format.as_ref() { + params.insert("format".to_string(), serde_json::json!(format)); + } + } + _ => {} + } + Some(serde_json::Value::Object(params)) +} + +fn readonly_query_requires_local_config(options: &CliOptions) -> bool { + matches!(options.command, CliCommand::ResourceIndex) + && options.config.import_resource_repository_path.is_some() +} + +fn build_readonly_query_report( + options: &CliOptions, + method: &str, +) -> anyhow::Result { + match method { + RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir), + RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report( + &options.state_dir, + textunit_query_from_options(options), + options.query_offset, + options.query_limit, + ), + RPC_METHOD_PARSE_ERRORS => build_parse_errors_report( + &options.state_dir, + textunit_query_from_options(options), + options.query_offset, + options.query_limit, + ), + RPC_METHOD_LOCALIZED_STATUS => { + build_localized_status_report(&options.state_dir, &options.config) + } + RPC_METHOD_RESOURCE_INDEX => build_resource_index_report( + &options.state_dir, + &options.config, + resource_index_query_from_options(options), + options.query_offset, + options.query_limit, + ), + _ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")), + } +} + +fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> { + let has_resource_index_filter = + options.query_resource_type.is_some() || options.query_hash.is_some(); + let has_textunit_filter = options.query_destination.is_some() + || options.query_archive_entry.is_some() + || options.query_path_id.is_some() + || options.query_class_id.is_some() + || options.query_field_path.is_some() + || options.query_format.is_some(); + + match options.command { + CliCommand::ResourceIndex => { + if has_textunit_filter { + return Err(anyhow::anyhow!( + "--destination/--archive-entry/--path-id/--class-id/--field-path/--format 只适用于 parse-text-units 或 parse-errors" + )); + } + } + CliCommand::ParseTextUnits | CliCommand::ParseErrors => { + if has_resource_index_filter { + return Err(anyhow::anyhow!( + "--resource-type/--hash 只适用于 resource-index" + )); + } + } + CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => { + return Err(anyhow::anyhow!( + "查询过滤参数只适用于 resource-index、parse-text-units 或 parse-errors" + )); + } + _ => {} + } + Ok(()) +} + +fn is_write_patch_command(command: CliCommand) -> bool { + matches!( + command, + CliCommand::PatchApply + | CliCommand::UnityFsPatchTextAsset + | CliCommand::UnityFsPatchStringField + | CliCommand::UnityFsPatchField + ) +} + +fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> { + match options.command { + CliCommand::PatchApply => { + let _ = patch_apply_params_from_options(options)?; + reject_unityfs_write_options(options, "patch-apply")?; + } + CliCommand::UnityFsPatchTextAsset => { + let _ = unityfs_text_asset_params_from_options(options)?; + reject_patch_apply_options(options, "unityfs-patch-text-asset")?; + if options.unityfs_field_path.is_some() + || options.unityfs_replacement_text.is_some() + || options.unityfs_expected_value.is_some() + { + return Err(anyhow::anyhow!( + "unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value" + )); + } + } + CliCommand::UnityFsPatchStringField => { + let _ = unityfs_string_field_params_from_options(options)?; + reject_patch_apply_options(options, "unityfs-patch-string-field")?; + if options.unityfs_expected_name.is_some() { + return Err(anyhow::anyhow!( + "unityfs-patch-string-field 不接受 --expected-name" + )); + } + } + CliCommand::UnityFsPatchField => { + let _ = unityfs_field_params_from_options(options)?; + reject_patch_apply_options(options, "unityfs-patch-field")?; + if options.unityfs_expected_name.is_some() + || options.unityfs_replacement_text.is_some() + || options.unityfs_expected_value.is_some() + { + return Err(anyhow::anyhow!( + "unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json" + )); + } + } + _ => {} + } + Ok(()) +} + +fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result { + Ok(PatchApplyParams { + kind: require_cli_option(options.patch_kind, "--patch-kind")?, + source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?, + patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?, + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + }) +} + +fn unityfs_text_asset_params_from_options( + options: &CliOptions, +) -> anyhow::Result { + Ok(UnityFsTextAssetPatchParams { + bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, + serialized_file_path: require_cli_option( + options.unityfs_serialized_file_path.clone(), + "--serialized-file", + )?, + path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, + replacement_path: require_cli_option( + options.unityfs_replacement_path.clone(), + "--replacement-file", + )?, + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + expected_name: options.unityfs_expected_name.clone(), + }) +} + +fn unityfs_string_field_params_from_options( + options: &CliOptions, +) -> anyhow::Result { + let has_replacement_text = options.unityfs_replacement_text.is_some(); + let has_replacement_path = options.unityfs_replacement_path.is_some(); + if has_replacement_text == has_replacement_path { + return Err(anyhow::anyhow!( + "unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个" + )); + } + Ok(UnityFsStringFieldPatchParams { + bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, + serialized_file_path: require_cli_option( + options.unityfs_serialized_file_path.clone(), + "--serialized-file", + )?, + path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, + field_path: require_cli_option( + options.unityfs_field_path.clone(), + "--field-path/--string-field-path", + )?, + replacement_text: options.unityfs_replacement_text.clone(), + replacement_path: options.unityfs_replacement_path.clone(), + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + expected_value: options.unityfs_expected_value.clone(), + }) +} + +fn unityfs_field_params_from_options( + options: &CliOptions, +) -> anyhow::Result { + if options.unityfs_replacement_path.is_some() { + return Err(anyhow::anyhow!( + "unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json" + )); + } + Ok(UnityFsFieldPatchParams { + bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, + serialized_file_path: require_cli_option( + options.unityfs_serialized_file_path.clone(), + "--serialized-file", + )?, + path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, + field_path: require_cli_option( + options.unityfs_field_path.clone(), + "--field-path/--string-field-path", + )?, + replacement: require_cli_option( + options.unityfs_replacement_value.clone(), + "--replacement-json", + )?, + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + expected_value: options.unityfs_expected_semantic_value.clone(), + }) +} + +fn require_cli_option(value: Option, name: &str) -> anyhow::Result { + value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}")) +} + +fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> { + if options.patch_kind.is_some() + || options.patch_source_path.is_some() + || options.patch_patch_path.is_some() + { + return Err(anyhow::anyhow!( + "{command} 不接受 --patch-kind、--source-file 或 --patch-file" + )); + } + Ok(()) +} + +fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> { + if options.unityfs_bundle_path.is_some() + || options.unityfs_serialized_file_path.is_some() + || options.unityfs_path_id.is_some() + || options.unityfs_field_path.is_some() + || options.unityfs_replacement_path.is_some() + || options.unityfs_replacement_text.is_some() + || options.unityfs_expected_name.is_some() + || options.unityfs_expected_value.is_some() + || options.unityfs_replacement_value.is_some() + || options.unityfs_expected_semantic_value.is_some() + { + return Err(anyhow::anyhow!( + "{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令" + )); + } + Ok(()) +} + +fn resource_index_query_from_options(options: &CliOptions) -> ResourceQuery { + ResourceQuery { + resource_type: options.query_resource_type, + hash: options.query_hash.clone(), + path_pattern: options.query_path_pattern.clone(), + } +} + +fn textunit_query_from_options(options: &CliOptions) -> OfficialTextUnitQuery { + OfficialTextUnitQuery { + destination: options.query_destination.clone(), + path_pattern: options.query_path_pattern.clone(), + archive_entry: options.query_archive_entry.clone(), + path_id: options.query_path_id, + class_id: options.query_class_id, + field_path: options.query_field_path.clone(), + format: options.query_format.clone(), + } +} + +fn resource_type_rpc_label(resource_type: ResourceType) -> &'static str { + match resource_type { + ResourceType::AssetBundle => "asset_bundle", + ResourceType::Manifest => "manifest", + ResourceType::TableBundle => "table_bundle", + ResourceType::TextAsset => "text_asset", + ResourceType::Media => "media", + ResourceType::Other => "other", + } +} + fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> { let defaults = OfficialUpdateConfig::default(); let default_daemon_shape = !options.watch @@ -3112,7 +4258,11 @@ fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<& && !options.config.dry_run && !options.config.plan && options.config.audit_local == defaults.audit_local - && options.config.repair == defaults.repair; + && options.config.repair == defaults.repair + && options.config.import_repository == defaults.import_repository + && options.config.import_cas_root == defaults.import_cas_root + && options.config.import_resource_repository_path + == defaults.import_resource_repository_path; if !default_daemon_shape { return None; } @@ -3453,6 +4603,10 @@ impl HumanReport for OfficialUpdateReport { .join(", "), ); print_field("需要下载", format_bool(self.should_download)); + print_field( + "等待官方资源", + format_bool(self.waiting_for_official_resources), + ); print_field("首次同步", format_bool(self.is_initial)); print_field("强制刷新", format_bool(self.force)); print_field("本地审计", format_bool(self.audit_local)); @@ -3473,8 +4627,19 @@ 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.resource_change_set_path.as_ref()); + print_optional_path_field("Crowdin handoff", self.crowdin_handoff_path.as_ref()); print_optional_path_field("解析缓存", self.parse_cache_path.as_ref()); + print_optional_path_field("TextUnit 任务队列", self.textunit_task_queue_path.as_ref()); + print_optional_path_field( + "Crowdin TextUnit 队列", + self.crowdin_textunit_queue_path.as_ref(), + ); print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref()); + print_optional_path_field( + "启动器引导产物", + self.launcher_bootstrap_artifact_path.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)); print_optional_field("计划 URL 数", self.download_url_count); @@ -3488,6 +4653,13 @@ 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.resource_change_summary.as_ref() { + print_field("新增资源", summary.added_count); + print_field("变更资源", summary.modified_count); + print_field("删除资源", summary.removed_count); + print_field("解析候选", summary.parse_candidate_count); + print_field("Crowdin 候选", summary.translation_candidate_count); + } if let Some(summary) = self.parse_summary.as_ref() { print_field("解析缓存条目", summary.cache_entry_count); print_field("解析成功 bundle", summary.parsed_bundle_count); @@ -3495,14 +4667,63 @@ impl HumanReport for OfficialUpdateReport { print_field("解析不支持", summary.unsupported_count); print_field("解析失败", summary.failed_count); print_field("TextAsset", summary.text_asset_count); + print_field("TextUnit", summary.text_unit_count); + print_field("二进制 TextAsset", summary.skipped_binary_text_asset_count); + print_field("TextUnit 诊断", summary.text_unit_error_count); + } + if let Some(summary) = self.textunit_task_summary.as_ref() { + print_field("TextUnit 资源候选", summary.resource_candidate_count); + print_field("TextUnit 解析条目", summary.parse_entry_count); + print_field("TextUnit 任务", summary.queued_task_count); + print_field("增量 TextUnit", summary.text_unit_count); + print_field("TextUnit 无解析", summary.skipped_no_parse_entry_count); + print_field("TextUnit 无文本", summary.skipped_no_text_unit_count); + print_field("TextUnit 解析失败", summary.skipped_parse_failed_count); + print_field("TextUnit 不支持", summary.skipped_unsupported_count); } print_field("catalog marker", self.addressables_marker_checked_count); + if !self.unavailable_endpoints.is_empty() { + let unavailable = self + .unavailable_endpoints + .iter() + .map(|endpoint| { + format!( + "{}{} kind={} http={} {}", + endpoint_kind_label_for_human(endpoint.kind), + endpoint + .platform + .map(|platform| format!(" ({})", platform_label(platform))) + .unwrap_or_default(), + endpoint.error_kind, + endpoint + .http_status + .map(|status| status.to_string()) + .unwrap_or_else(|| "none".to_string()), + endpoint.url + ) + }) + .collect::>(); + print_list("不可用官方 endpoint", &unavailable, 8); + } print_list("变更 endpoint", &self.changed_endpoint_urls, 8); print_list("计划 URL", &self.download_urls, 8); Ok(()) } } +fn endpoint_kind_label_for_human(kind: YostarJpResourceEndpointKind) -> &'static str { + match kind { + YostarJpResourceEndpointKind::TableCatalog => "table_catalog", + YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash", + YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog", + YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash", + YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info", + YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash", + YostarJpResourceEndpointKind::MediaCatalog => "media_catalog", + YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash", + } +} + impl HumanReport for CommandReport where T: Serialize + HumanReport, @@ -3515,12 +4736,52 @@ where } } +impl HumanReport for PatchApplyReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("Patch 类型", self.kind.as_str()); + print_path_field("源文件", &self.source_path); + print_path_field("Patch 文件", &self.patch_path); + print_path_field("目标文件", &self.target_path); + print_field("源字节", self.source_size); + print_field("Patch 字节", self.patch_size); + print_field("目标字节", self.target_size); + print_field("源 BLAKE3", &self.source_blake3); + print_field("Patch BLAKE3", &self.patch_blake3); + print_field("目标 BLAKE3", &self.target_blake3); + Ok(()) + } +} + +impl HumanReport for UnityFsPatchReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_path_field("源 bundle", &self.bundle_path); + print_field("Serialized 文件", &self.serialized_file_path); + print_field("Path ID", self.path_id); + print_optional_field("字段路径", self.field_path.as_deref()); + print_path_field("目标 bundle", &self.target_path); + print_field("源字节", self.source_size); + print_field("替换字节", self.replacement_size); + print_field("目标字节", self.target_size); + print_field("源 BLAKE3", &self.source_blake3); + print_field("替换 BLAKE3", &self.replacement_blake3); + print_field("目标 BLAKE3", &self.target_blake3); + Ok(()) + } +} + impl HumanReport for DaemonStartReport { fn print_human(&self) -> anyhow::Result<()> { print_title(self.message); print_field("状态", self.status); print_field("PID", self.pid); print_path_field("资源目录", &self.resource_output_root); + print_path_field("汉化目录", &self.localized_output_root); print_path_field("状态目录", &self.state_dir); print_path_field("socket", &self.socket_path); print_path_field("日志", &self.log_path); @@ -3554,6 +4815,7 @@ impl HumanReport for DaemonStatusReport { print_daemon_version_state_summary(version_state); } print_optional_path_field("资源目录", self.resource_output_root.as_ref()); + print_optional_path_field("汉化目录", self.localized_output_root.as_ref()); print_optional_path_field("版本状态", self.version_state_path.as_ref()); print_path_field("状态目录", &self.state_dir); print_path_field("socket", &self.socket_path); @@ -4307,6 +5569,7 @@ fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> any pid: std::process::id(), state: "started".to_string(), resource_output_root: OfficialUpdateConfig::default().output_root, + localized_output_root: Some(OfficialUpdateConfig::default().localized_output_root), state_dir: state_dir.to_path_buf(), log_path: daemon_log_path(state_dir), structured_log_path: Some(daemon_structured_log_path(state_dir)), @@ -4554,6 +5817,19 @@ fn daemon_child_args(options: &CliOptions) -> Vec { 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 config.import_repository { + args.push("--import-repository".to_string()); + } else { + args.push("--no-import-repository".to_string()); + } + if let Some(cas_root) = config.import_cas_root.as_ref() { + args.push("--import-cas-root".to_string()); + args.push(cas_root.to_string_lossy().to_string()); + } + if let Some(repository_path) = config.import_resource_repository_path.as_ref() { + args.push("--import-resource-db".to_string()); + args.push(repository_path.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()); @@ -5039,6 +6315,12 @@ const ENV_TEMPLATE: &str = r#"# BlueArchive Toolkit 配置文件(bat 首次启 BAT_OUTPUT=./bat-resources # 汉化产物输出根目录(默认 ./bat-localized,与官方原版资源分离) BAT_LOCALIZED_OUTPUT=./bat-localized +# 启用官方 release 导入 CAS + ResourceRepository;默认关闭。 +BAT_IMPORT_REPOSITORY=0 +# 官方资源 CAS 目录;未设置时默认 /.cas +BAT_IMPORT_CAS_ROOT= +# 官方资源 SQLite 索引;未设置时默认 /resources.sqlite +BAT_IMPORT_RESOURCE_DB= # 自动发现 app-version / connection-group / server-info(无参启动建议保持 1) BAT_AUTO_DISCOVER=1 # 后台状态目录(bat.sock / 日志 / 任务历史等;默认 /tmp/bat-pid) @@ -5212,6 +6494,15 @@ fn apply_bat_env_overrides( if let Some(v) = value("BAT_LOCALIZED_OUTPUT") { options.config.localized_output_root = PathBuf::from(v); } + if let Some(v) = value("BAT_IMPORT_REPOSITORY") { + options.config.import_repository = parse_env_bool("BAT_IMPORT_REPOSITORY", &v)?; + } + if let Some(v) = value("BAT_IMPORT_CAS_ROOT") { + options.config.import_cas_root = Some(PathBuf::from(v)); + } + if let Some(v) = value("BAT_IMPORT_RESOURCE_DB") { + options.config.import_resource_repository_path = Some(PathBuf::from(v)); + } if let Some(v) = value("BAT_STATE_DIR") { options.state_dir = PathBuf::from(v); } @@ -5324,6 +6615,42 @@ fn parse_args_with_env( ensure_command_not_set(options.command, "repair")?; options.command = CliCommand::Repair; } + "parse-status" => { + ensure_command_not_set(options.command, "parse-status")?; + options.command = CliCommand::ParseStatus; + } + "parse-text-units" => { + ensure_command_not_set(options.command, "parse-text-units")?; + options.command = CliCommand::ParseTextUnits; + } + "parse-errors" => { + ensure_command_not_set(options.command, "parse-errors")?; + options.command = CliCommand::ParseErrors; + } + "localized-status" => { + ensure_command_not_set(options.command, "localized-status")?; + options.command = CliCommand::LocalizedStatus; + } + "resource-index" => { + ensure_command_not_set(options.command, "resource-index")?; + options.command = CliCommand::ResourceIndex; + } + "patch-apply" => { + ensure_command_not_set(options.command, "patch-apply")?; + options.command = CliCommand::PatchApply; + } + "unityfs-patch-text-asset" => { + ensure_command_not_set(options.command, "unityfs-patch-text-asset")?; + options.command = CliCommand::UnityFsPatchTextAsset; + } + "unityfs-patch-string-field" => { + ensure_command_not_set(options.command, "unityfs-patch-string-field")?; + options.command = CliCommand::UnityFsPatchStringField; + } + "unityfs-patch-field" => { + ensure_command_not_set(options.command, "unityfs-patch-field")?; + options.command = CliCommand::UnityFsPatchField; + } "doctor" => { ensure_command_not_set(options.command, "doctor")?; options.command = CliCommand::Doctor; @@ -5385,6 +6712,24 @@ fn parse_args_with_env( PathBuf::from(next_option_value(&mut args, &flag)?); options.output_explicit = true; } + "--import-repository" => { + options.config.import_repository = true; + options.sync_option_explicit = true; + } + "--no-import-repository" => { + options.config.import_repository = false; + options.sync_option_explicit = true; + } + "--import-cas-root" => { + options.config.import_cas_root = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.sync_option_explicit = true; + } + "--import-resource-db" | "--resource-db" => { + options.config.import_resource_repository_path = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.sync_option_explicit = true; + } "--state-dir" | "--pid-dir" => { options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?); } @@ -5523,6 +6868,148 @@ fn parse_args_with_env( return Err(anyhow::anyhow!("--tail 必须大于 0")); } } + "--offset" => { + options.query_offset = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + options.query_option_explicit = true; + } + "--limit" => { + options.query_limit = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + if options.query_limit == 0 || options.query_limit > 1000 { + return Err(anyhow::anyhow!("--limit 必须在 1..=1000 范围内")); + } + options.query_option_explicit = true; + } + "--resource-type" => { + options.query_resource_type = Some(parse_resource_type_param(&next_option_value( + &mut args, &flag, + )?)?); + options.query_option_explicit = true; + } + "--hash" => { + options.query_hash = Some(next_option_value(&mut args, &flag)?); + options.query_option_explicit = true; + } + "--path-pattern" => { + options.query_path_pattern = Some(next_option_value(&mut args, &flag)?); + options.query_option_explicit = true; + } + "--destination" => { + options.query_destination = Some(next_option_value(&mut args, &flag)?); + options.query_option_explicit = true; + } + "--archive-entry" => { + options.query_archive_entry = Some(next_option_value(&mut args, &flag)?); + options.query_option_explicit = true; + } + "--path-id" => { + options.query_path_id = Some( + next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?, + ); + options.query_option_explicit = true; + } + "--class-id" => { + options.query_class_id = Some( + next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?, + ); + options.query_option_explicit = true; + } + "--field-path" => { + if matches!( + options.command, + CliCommand::UnityFsPatchStringField | CliCommand::UnityFsPatchField + ) { + options.unityfs_field_path = Some(next_option_value(&mut args, &flag)?); + options.write_patch_option_explicit = true; + } else { + options.query_field_path = Some(next_option_value(&mut args, &flag)?); + options.query_option_explicit = true; + } + } + "--format" => { + options.query_format = Some(next_option_value(&mut args, &flag)?); + options.query_option_explicit = true; + } + "--patch-kind" => { + options.patch_kind = Some(parse_patch_apply_kind(&next_option_value( + &mut args, &flag, + )?)?); + options.write_patch_option_explicit = true; + } + "--source-file" => { + options.patch_source_path = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.write_patch_option_explicit = true; + } + "--patch-file" => { + options.patch_patch_path = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.write_patch_option_explicit = true; + } + "--target-file" => { + options.patch_target_path = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.write_patch_option_explicit = true; + } + "--bundle-file" => { + options.unityfs_bundle_path = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.write_patch_option_explicit = true; + } + "--serialized-file" => { + options.unityfs_serialized_file_path = Some(next_option_value(&mut args, &flag)?); + options.write_patch_option_explicit = true; + } + "--object-path-id" => { + options.unityfs_path_id = Some( + next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?, + ); + options.write_patch_option_explicit = true; + } + "--string-field-path" => { + options.unityfs_field_path = Some(next_option_value(&mut args, &flag)?); + options.write_patch_option_explicit = true; + } + "--replacement-file" => { + options.unityfs_replacement_path = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.write_patch_option_explicit = true; + } + "--replacement-text" => { + options.unityfs_replacement_text = Some(next_option_value(&mut args, &flag)?); + options.write_patch_option_explicit = true; + } + "--expected-name" => { + options.unityfs_expected_name = Some(next_option_value(&mut args, &flag)?); + options.write_patch_option_explicit = true; + } + "--expected-value" => { + options.unityfs_expected_value = Some(next_option_value(&mut args, &flag)?); + options.write_patch_option_explicit = true; + } + "--replacement-json" => { + options.unityfs_replacement_value = Some(parse_replacement_value_json( + &next_option_value(&mut args, &flag)?, + &flag, + )?); + options.write_patch_option_explicit = true; + } + "--expected-json" => { + options.unityfs_expected_semantic_value = Some(parse_replacement_value_json( + &next_option_value(&mut args, &flag)?, + &flag, + )?); + options.write_patch_option_explicit = true; + } "--help" | "-h" => { print_usage(&binary); std::process::exit(0); @@ -5543,6 +7030,12 @@ fn parse_args_with_env( } } + if !is_write_patch_command(options.command) && options.write_patch_option_explicit { + return Err(anyhow::anyhow!( + "写入 patch 参数只适用于 patch-apply、unityfs-patch-text-asset、unityfs-patch-string-field 或 unityfs-patch-field" + )); + } + match options.command { CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { if options.sync_option_explicit @@ -5557,6 +7050,36 @@ fn parse_args_with_env( options.progress = false; options.banner = false; } + CliCommand::ParseStatus + | CliCommand::ParseTextUnits + | CliCommand::ParseErrors + | CliCommand::LocalizedStatus + | CliCommand::ResourceIndex => { + validate_readonly_query_options(&options)?; + options.progress = false; + options.banner = false; + } + CliCommand::PatchApply + | CliCommand::UnityFsPatchTextAsset + | CliCommand::UnityFsPatchStringField + | CliCommand::UnityFsPatchField => { + if options.output_explicit { + return Err(anyhow::anyhow!( + "写入 patch 命令不使用 --output;请用 --target-file 指定目标文件" + )); + } + if options.sync_option_explicit + || options.proxy_option_explicit + || tools_are_non_default(&options.config, &options.env_baseline_config) + { + return Err(anyhow::anyhow!( + "写入 patch 命令只接受文件 patch 参数、--state-dir、--json/--human" + )); + } + validate_write_patch_options(&options)?; + options.progress = false; + options.banner = false; + } CliCommand::Doctor | CliCommand::CleanStable => { if options.sync_option_explicit { return Err(anyhow::anyhow!( @@ -5718,6 +7241,15 @@ fn print_usage(binary: &str) { eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); eprintln!(" verify Verify remote plan, local manifest, and official seed hashes"); eprintln!(" repair Redownload resources that fail local verification"); + eprintln!(" parse-status Show current official parse-cache status"); + eprintln!(" parse-text-units Query current official TextUnit detail index"); + eprintln!(" parse-errors Query current official parse/extraction diagnostics"); + eprintln!(" localized-status Show localized release status for current official release"); + eprintln!(" resource-index Query CAS + ResourceRepository index"); + eprintln!(" patch-apply Apply a Binary/JSON/Text patch file"); + eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object"); + eprintln!(" unityfs-patch-string-field Patch one UnityFS TypeTree string field"); + eprintln!(" unityfs-patch-field Patch one UnityFS TypeTree field with semantic JSON"); eprintln!(" status Show daemon state"); eprintln!(" stop Stop daemon"); eprintln!( @@ -5754,6 +7286,12 @@ fn print_usage(binary: &str) { eprintln!( " --localized-output Localized output root (default: ./bat-localized)" ); + eprintln!( + " --import-repository Import verified release into CAS + ResourceRepository" + ); + eprintln!(" --no-import-repository Disable CAS + ResourceRepository import"); + eprintln!(" --import-cas-root CAS root for official release imports"); + eprintln!(" --import-resource-db SQLite ResourceRepository path"); eprintln!(" --snapshot Override snapshot path (default: /current/official-sync-snapshot.json)"); eprintln!(" --curl curl executable (default: curl)"); eprintln!(" --proxy curl proxy override (default: auto from env)"); @@ -5765,6 +7303,41 @@ fn print_usage(binary: &str) { eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit"); eprintln!(" --repair | --no-repair Enable/disable automatic repair"); eprintln!(); + eprintln!("Read-only queries:"); + eprintln!(" --offset Query offset for resource-index/parse-text-units/parse-errors"); + eprintln!(" --limit Query limit for resource-index/parse-text-units/parse-errors (1..=1000)"); + eprintln!(" --resource-type asset_bundle, manifest, table_bundle, text_asset, media, other"); + eprintln!(" --hash Filter resource-index by full CAS hash"); + eprintln!( + " --path-pattern Filter resource-index or parse detail by path pattern" + ); + eprintln!(" --destination Filter parse detail by official destination"); + eprintln!(" --archive-entry Filter parse detail by ZIP/archive entry"); + eprintln!(" --path-id Filter parse detail by Unity object path ID"); + eprintln!(" --class-id Filter parse detail by Unity class ID"); + eprintln!(" --field-path Filter parse detail, or TypeTree field path after UnityFS field patch commands"); + eprintln!(" --format Filter parse text units by payload format"); + eprintln!(); + eprintln!("Write patch:"); + eprintln!(" --patch-kind Patch type for patch-apply"); + eprintln!(" --source-file Source file for patch-apply"); + eprintln!(" --patch-file Patch JSON file for patch-apply"); + eprintln!(" --bundle-file Source UnityFS bundle file"); + eprintln!(" --serialized-file Serialized file path inside UnityFS"); + eprintln!(" --object-path-id Unity object path ID for UnityFS patch"); + eprintln!( + " --string-field-path Deprecated alias for UnityFS TypeTree field path" + ); + eprintln!(" --replacement-file Replacement bytes or UTF-8 string file"); + eprintln!(" --replacement-text Inline replacement text for string-field patch"); + eprintln!( + " --replacement-json Semantic replacement, e.g. signed/enum/bit_field JSON" + ); + eprintln!(" --expected-name Expected TextAsset name"); + eprintln!(" --expected-value Expected source string value"); + eprintln!(" --expected-json Optional expected semantic source value"); + eprintln!(" --target-file Target output file written atomically"); + eprintln!(); eprintln!("Daemon:"); eprintln!(" --watch Run in foreground loop"); eprintln!(" --daemon Start detached watch process"); @@ -5869,6 +7442,9 @@ mod tests { &[ ("BAT_OUTPUT", "/srv/bat"), ("BAT_LOCALIZED_OUTPUT", "/srv/bat-localized"), + ("BAT_IMPORT_REPOSITORY", "1"), + ("BAT_IMPORT_CAS_ROOT", "/srv/bat-cas"), + ("BAT_IMPORT_RESOURCE_DB", "/srv/bat/resources.sqlite"), ("BAT_AUTO_DISCOVER", "1"), ("BAT_STATE_DIR", "/srv/state"), ("BAT_INTERVAL_SECONDS", "120"), @@ -5880,6 +7456,15 @@ mod tests { options.config.localized_output_root, PathBuf::from("/srv/bat-localized") ); + assert!(options.config.import_repository); + assert_eq!( + options.config.import_cas_root, + Some(PathBuf::from("/srv/bat-cas")) + ); + assert_eq!( + options.config.import_resource_repository_path, + Some(PathBuf::from("/srv/bat/resources.sqlite")) + ); assert!(options.config.auto_discover); assert_eq!(options.state_dir, PathBuf::from("/srv/state")); assert_eq!(options.interval, Duration::from_secs(120)); @@ -6000,6 +7585,9 @@ mod tests { } assert!(keys.contains(&"BAT_OUTPUT".to_string())); assert!(keys.contains(&"BAT_LOCALIZED_OUTPUT".to_string())); + assert!(keys.contains(&"BAT_IMPORT_REPOSITORY".to_string())); + assert!(keys.contains(&"BAT_IMPORT_CAS_ROOT".to_string())); + assert!(keys.contains(&"BAT_IMPORT_RESOURCE_DB".to_string())); assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string())); } @@ -6138,6 +7726,11 @@ mod tests { "Windows,Android", "--snapshot", "/tmp/snapshot.json", + "--import-repository", + "--import-cas-root", + "/tmp/bat-cas", + "--import-resource-db", + "/tmp/bat-resources.sqlite", "--dry-run", "--plan", ]) @@ -6153,6 +7746,12 @@ mod tests { config.snapshot_path, Some(PathBuf::from("/tmp/snapshot.json")) ); + assert!(config.import_repository); + assert_eq!(config.import_cas_root, Some(PathBuf::from("/tmp/bat-cas"))); + assert_eq!( + config.import_resource_repository_path, + Some(PathBuf::from("/tmp/bat-resources.sqlite")) + ); assert!(config.dry_run); assert!(config.plan); assert!(config.audit_local); @@ -6350,10 +7949,313 @@ mod tests { ("logs", CliCommand::Logs), ("doctor", CliCommand::Doctor), ("clean-stable", CliCommand::CleanStable), + ("parse-status", CliCommand::ParseStatus), + ("parse-text-units", CliCommand::ParseTextUnits), + ("parse-errors", CliCommand::ParseErrors), + ("localized-status", CliCommand::LocalizedStatus), + ("resource-index", CliCommand::ResourceIndex), ] { let options = parse(&["bat", value]).unwrap(); assert_eq!(options.command, expected); } + + let index = parse(&[ + "bat", + "resource-index", + "--resource-type", + "text_asset", + "--hash", + "abc", + "--path-pattern", + "TextAssets/**", + "--offset", + "5", + "--limit", + "25", + ]) + .unwrap(); + assert_eq!(index.command, CliCommand::ResourceIndex); + assert_eq!(index.query_resource_type, Some(ResourceType::TextAsset)); + assert_eq!(index.query_hash.as_deref(), Some("abc")); + assert_eq!(index.query_path_pattern.as_deref(), Some("TextAssets/**")); + assert_eq!(index.query_offset, 5); + assert_eq!(index.query_limit, 25); + + let text_units = parse(&[ + "bat", + "parse-text-units", + "--destination", + "Bundle/test.bundle", + "--archive-entry", + "assets/scenario.bundle", + "--path-id", + "42", + "--class-id", + "114", + "--field-path", + "Scenario.Message", + "--format", + "plain", + "--path-pattern", + "Bundle/**", + "--offset", + "2", + "--limit", + "10", + ]) + .unwrap(); + assert_eq!(text_units.command, CliCommand::ParseTextUnits); + assert_eq!( + text_units.query_destination.as_deref(), + Some("Bundle/test.bundle") + ); + assert_eq!(text_units.query_path_id, Some(42)); + assert_eq!(text_units.query_class_id, Some(114)); + assert_eq!( + text_units.query_field_path.as_deref(), + Some("Scenario.Message") + ); + assert_eq!(text_units.query_format.as_deref(), Some("plain")); + assert_eq!(text_units.query_offset, 2); + assert_eq!(text_units.query_limit, 10); + + let error = parse(&["bat", "parse-status", "--limit", "10"]).unwrap_err(); + assert!(error.to_string().contains("查询过滤参数只适用于")); + + let error = parse(&["bat", "parse-text-units", "--hash", "abc"]).unwrap_err(); + assert!(error.to_string().contains("--resource-type/--hash")); + } + + #[test] + fn parses_write_patch_commands_without_output_confusion() { + let patch = parse(&[ + "bat", + "patch-apply", + "--patch-kind", + "text", + "--source-file", + "/tmp/source.txt", + "--patch-file", + "/tmp/source.patch.json", + "--target-file", + "/tmp/target.txt", + ]) + .unwrap(); + assert_eq!(patch.command, CliCommand::PatchApply); + assert_eq!(patch.patch_kind, Some(PatchApplyKind::Text)); + assert_eq!( + patch.patch_source_path, + Some(PathBuf::from("/tmp/source.txt")) + ); + + let text_asset = parse(&[ + "bat", + "unityfs-patch-text-asset", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "7", + "--replacement-file", + "/tmp/replacement.bytes", + "--expected-name", + "Scenario", + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(text_asset.command, CliCommand::UnityFsPatchTextAsset); + assert_eq!(text_asset.unityfs_path_id, Some(7)); + assert_eq!( + text_asset.unityfs_expected_name.as_deref(), + Some("Scenario") + ); + + let string_field = parse(&[ + "bat", + "unityfs-patch-string-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "9", + "--string-field-path", + "entry.message", + "--replacement-text", + "老師", + "--expected-value", + "先生", + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(string_field.command, CliCommand::UnityFsPatchStringField); + assert_eq!( + string_field.unityfs_field_path.as_deref(), + Some("entry.message") + ); + assert_eq!( + string_field.unityfs_replacement_text.as_deref(), + Some("老師") + ); + + let field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "11", + "--field-path", + "scores[1]", + "--expected-json", + r#"{"kind":"signed","value":20}"#, + "--replacement-json", + r#"{"kind":"signed","value":42}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(field.command, CliCommand::UnityFsPatchField); + assert_eq!(field.unityfs_field_path.as_deref(), Some("scores[1]")); + assert_eq!( + field.unityfs_expected_semantic_value, + Some(UnitySerializedReplacementValue::Signed(20)) + ); + assert_eq!( + field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::Signed(42)) + ); + + let enum_field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "12", + "--field-path", + "difficulty", + "--expected-json", + r#"{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":2}}"#, + "--replacement-json", + r#"{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":3}}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(enum_field.command, CliCommand::UnityFsPatchField); + assert_eq!( + enum_field.unityfs_expected_semantic_value, + Some(UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 2, + }) + ); + assert_eq!( + enum_field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 3, + }) + ); + + let bitfield_field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "13", + "--field-path", + "target_layers", + "--expected-json", + r#"{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":5}}"#, + "--replacement-json", + r#"{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":9}}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(bitfield_field.command, CliCommand::UnityFsPatchField); + assert_eq!( + bitfield_field.unityfs_expected_semantic_value, + Some(UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 5, + }) + ); + assert_eq!( + bitfield_field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 9, + }) + ); + + let map_field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "12", + "--field-path", + "texts", + "--replacement-json", + r#"{"kind":"map","value":[{"kind":"object","value":[{"name":"first","value":{"kind":"string","value":"jp"}},{"name":"second","value":{"kind":"string","value":"こんにちは"}}]}]}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(map_field.command, CliCommand::UnityFsPatchField); + assert_eq!(map_field.unityfs_field_path.as_deref(), Some("texts")); + assert_eq!( + map_field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::Map(vec![ + UnitySerializedReplacementValue::Object(vec![ + bat_assetbundle::UnitySerializedFieldReplacement { + name: "first".to_string(), + value: UnitySerializedReplacementValue::String("jp".to_string()), + }, + bat_assetbundle::UnitySerializedFieldReplacement { + name: "second".to_string(), + value: UnitySerializedReplacementValue::String("こんにちは".to_string()), + }, + ]), + ])) + ); + + let error = parse(&[ + "bat", + "patch-apply", + "--patch-kind", + "text", + "--source-file", + "/tmp/source.txt", + "--patch-file", + "/tmp/source.patch.json", + "--output", + "/tmp/wrong", + "--target-file", + "/tmp/target.txt", + ]) + .unwrap_err(); + assert!(error.to_string().contains("--target-file")); } #[test] @@ -6386,6 +8288,11 @@ mod tests { "/tmp/daemon-output", "--localized-output", "/tmp/daemon-localized", + "--import-repository", + "--import-cas-root", + "/tmp/daemon-cas", + "--import-resource-db", + "/tmp/daemon-resources.sqlite", "--state-dir", "/tmp/daemon-state", "--curl", @@ -6413,6 +8320,13 @@ mod tests { assert!(args .windows(2) .any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"])); + assert!(args.contains(&"--import-repository".to_string())); + assert!(args + .windows(2) + .any(|pair| pair == ["--import-cas-root", "/tmp/daemon-cas"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--import-resource-db", "/tmp/daemon-resources.sqlite"])); assert!(args .windows(2) .any(|pair| pair == ["--state-dir", "/tmp/daemon-state"])); @@ -6522,17 +8436,26 @@ mod tests { #[test] fn is_pending_rpc_method_covers_planned_namespaces() { - assert!(is_pending_rpc_method("patch.apply")); + assert!(is_pending_rpc_method("patch.build")); assert!(is_pending_rpc_method("unityfs.inspect")); assert!(is_pending_rpc_method("task.create")); assert!(is_pending_rpc_method("daemon.restart")); assert!(is_pending_rpc_method("daemon.clean-stable")); - // sync/verify/repair、task.cancel/logs、catalog.* 与 resource.manifest 已实现。 + // sync/verify/repair、task.cancel/logs、catalog.*、resource.manifest 和 + // 文件级 patch/unityfs 写入方法已实现。 + assert!(!is_pending_rpc_method("patch.apply")); + assert!(!is_pending_rpc_method("unityfs.patch_text_asset")); + assert!(!is_pending_rpc_method("unityfs.patch_string_field")); + assert!(!is_pending_rpc_method("unityfs.patch_field")); assert!(!is_pending_rpc_method("resource.sync")); assert!(!is_pending_rpc_method("resource.verify")); assert!(!is_pending_rpc_method("resource.repair")); assert!(!is_pending_rpc_method("resource.manifest")); assert!(!is_pending_rpc_method("resource.list")); + assert!(!is_pending_rpc_method("parse.status")); + assert!(!is_pending_rpc_method("parse.text_units")); + assert!(!is_pending_rpc_method("parse.errors")); + assert!(!is_pending_rpc_method("localized.status")); assert!(!is_pending_rpc_method("catalog.status")); assert!(!is_pending_rpc_method("catalog.refresh")); assert!(!is_pending_rpc_method("task.cancel")); @@ -6577,11 +8500,15 @@ mod tests { } fn test_task_context() -> DaemonTaskContext { + test_task_context_with_config(OfficialUpdateConfig::default()) + } + + fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTaskContext { let (queue, _rx) = mpsc::channel::(); DaemonTaskContext { registry: TaskRegistry::new(), queue, - base_config: OfficialUpdateConfig::default(), + base_config, } } @@ -6607,7 +8534,7 @@ mod tests { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); let envelope = dispatch_rpc_method( - &rpc_request("patch.apply", None), + &rpc_request("patch.build", None), temp.path(), &control, &test_task_context(), @@ -6618,6 +8545,57 @@ mod tests { assert_eq!(value["error"]["code"], "BAT-ERR-700003"); } + #[test] + fn dispatch_patch_apply_requires_params() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("patch.apply", None), + temp.path(), + &control, + &test_task_context(), + "req-test-patch-params".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700002"); + } + + #[test] + fn dispatch_patch_apply_writes_text_patch_target() { + let temp = tempfile::TempDir::new().unwrap(); + let source_path = temp.path().join("source.txt"); + let patch_path = temp.path().join("patch.json"); + let target_path = temp.path().join("target.txt"); + fs::write(&source_path, "先生、こんにちは").unwrap(); + let patch = bat_patch::text::diff("先生、こんにちは", "老師、你好"); + fs::write(&patch_path, serde_json::to_vec(&patch).unwrap()).unwrap(); + + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request( + "patch.apply", + Some(serde_json::json!({ + "kind": "text", + "source_path": source_path, + "patch_path": patch_path, + "target_path": target_path, + })), + ), + temp.path(), + &control, + &test_task_context(), + "req-test-patch-apply".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["command"], "patch.apply"); + assert_eq!( + fs::read_to_string(temp.path().join("target.txt")).unwrap(), + "老師、你好" + ); + } + #[test] fn dispatch_task_list_returns_empty_ok_envelope() { let temp = tempfile::TempDir::new().unwrap(); @@ -7340,6 +9318,18 @@ mod tests { assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + let options = parse(&["bat", "refresh", "--import-repository"]).unwrap(); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + + let options = parse(&[ + "bat", + "refresh", + "--import-resource-db", + "/tmp/resources.sqlite", + ]) + .unwrap(); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + let options = parse(&["bat", "repair"]).unwrap(); assert!(!refresh_should_use_daemon_rpc(&options, "repair")); assert_eq!( @@ -7392,6 +9382,50 @@ mod tests { assert_eq!(seen[0].1, None); } + #[test] + fn readonly_resource_index_command_uses_rpc_with_filters() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); + let seen_calls = Arc::clone(&seen); + let options = parse(&[ + "bat", + "resource-index", + "--json", + "--state-dir", + state_dir.to_str().unwrap(), + "--resource-type", + "asset_bundle", + "--offset", + "2", + "--limit", + "3", + ]) + .unwrap(); + + run_readonly_query_command_with_rpc( + &options, + |_| true, + move |_state_dir, method, params| { + seen_calls + .lock() + .unwrap() + .push((method.to_string(), params.clone())); + Ok(serde_json::json!({ "available": true, "entries": [] })) + }, + ) + .unwrap(); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_INDEX); + let params = seen[0].1.as_ref().unwrap(); + assert_eq!(params["resource_type"], "asset_bundle"); + assert_eq!(params["offset"], 2); + assert_eq!(params["limit"], 3); + } + #[test] fn explicit_no_quiet_up_to_date_overrides_watch_default() { let options = parse(&[ @@ -7560,11 +9594,24 @@ mod tests { } fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile { + test_daemon_status_file_with_localized( + state_dir, + output_root, + &output_root.with_file_name("localized"), + ) + } + + fn test_daemon_status_file_with_localized( + state_dir: &Path, + output_root: &Path, + localized_output_root: &Path, + ) -> DaemonStatusFile { DaemonStatusFile { version: DAEMON_STATUS_VERSION, pid: std::process::id(), state: "sleeping".to_string(), resource_output_root: output_root.to_path_buf(), + localized_output_root: Some(localized_output_root.to_path_buf()), state_dir: state_dir.to_path_buf(), log_path: daemon_log_path(state_dir), structured_log_path: Some(daemon_structured_log_path(state_dir)), @@ -7893,6 +9940,509 @@ mod tests { assert_eq!(entries[0]["destination"], "c"); } + fn write_resource_index_fixture(repository_path: &Path) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let repository = SqliteResourceRepository::new(repository_path) + .await + .unwrap(); + for resource in [ + test_resource( + "official/a", + "TableBundles/a.bytes", + ResourceType::TableBundle, + ), + test_resource("official/b", "Bundles/b.bundle", ResourceType::AssetBundle), + test_resource("official/c", "TextAssets/c.json", ResourceType::TextAsset), + ] { + repository.add(resource).await.unwrap(); + } + }); + } + + fn test_resource(id: &str, path: &str, resource_type: ResourceType) -> Resource { + let metadata = if id == "official/c" { + bat_core::domain::ResourceMetadata { + official_release_id: Some("v-current".to_string()), + platform: Some("windows".to_string()), + bundle_path: Some(path.to_string()), + text_assets: vec!["Scenario".to_string()], + text_unit_count: 4, + text_unit_formats: vec!["json".to_string()], + ..bat_core::domain::ResourceMetadata::default() + } + } else { + bat_core::domain::ResourceMetadata::default() + }; + Resource { + id: id.to_string(), + local_path: PathBuf::from(path), + entry: bat_core::domain::ResourceEntry { + path: path.to_string(), + hash: format!("{id}-hash"), + size: 10, + resource_type, + address: None, + dependencies: Vec::new(), + crc: None, + }, + metadata, + } + } + + #[test] + fn dispatch_resource_index_reports_unavailable_without_database() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let repository_path = temp.path().join("missing/resources.sqlite"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + let context = test_task_context_with_config(OfficialUpdateConfig { + import_repository: true, + import_resource_repository_path: Some(repository_path.clone()), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request("resource.index", None), + &state_dir, + &new_daemon_control(), + &context, + "req-index-0".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + assert_eq!( + value["data"]["repository_path"].as_str().unwrap(), + repository_path.to_str().unwrap() + ); + assert!(!repository_path.exists()); + } + + #[test] + fn dispatch_resource_index_filters_and_paginates_repository() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let repository_path = temp.path().join("resources.sqlite"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + write_resource_index_fixture(&repository_path); + let context = test_task_context_with_config(OfficialUpdateConfig { + import_repository: true, + import_resource_repository_path: Some(repository_path.clone()), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.index", + Some(serde_json::json!({ + "resource_type": "text_asset", + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &context, + "req-index-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!( + value["data"]["repository_path"].as_str().unwrap(), + repository_path.to_str().unwrap() + ); + assert_eq!(value["data"]["total_entries"], 1); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["id"], "official/c"); + assert_eq!(entries[0]["entry"]["resource_type"], "TextAsset"); + assert_eq!(entries[0]["metadata"]["official_release_id"], "v-current"); + assert_eq!(entries[0]["metadata"]["platform"], "windows"); + assert_eq!(entries[0]["metadata"]["text_assets"][0], "Scenario"); + assert_eq!(entries[0]["metadata"]["text_unit_count"], 4); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.index", + Some(serde_json::json!({ "resource_type": "unknown" })), + ), + &state_dir, + &new_daemon_control(), + &context, + "req-index-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700002"); + } + + #[test] + fn dispatch_parse_status_reads_current_cache_summary() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let cache = bat_infrastructure::OfficialParseCache { + version: bat_infrastructure::OFFICIAL_PARSE_CACHE_VERSION, + generated_unix_seconds: 42, + summary: bat_infrastructure::OfficialParseSummary { + manifest_entry_count: 3, + cache_entry_count: 3, + parsed_bundle_count: 2, + text_unit_count: 7, + ..bat_infrastructure::OfficialParseSummary::default() + }, + entries: std::collections::BTreeMap::new(), + }; + fs::write( + current_dir.join(OFFICIAL_PARSE_CACHE_FILE), + serde_json::to_vec(&cache).unwrap(), + ) + .unwrap(); + let textunit_queue = bat_infrastructure::OfficialTextUnitTaskQueue { + queue_version: bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, + official_release_id: "v-current".to_string(), + previous_release_id: Some("v-previous".to_string()), + generated_unix_seconds: 43, + current_resource_root: current_dir.clone(), + summary: bat_infrastructure::OfficialTextUnitTaskSummary { + resource_candidate_count: 2, + parse_entry_count: 2, + queued_task_count: 1, + text_unit_count: 5, + ..bat_infrastructure::OfficialTextUnitTaskSummary::default() + }, + tasks: Vec::new(), + }; + bat_infrastructure::write_textunit_task_queue_at(¤t_dir, &textunit_queue).unwrap(); + write_textunit_index_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request("parse.status", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-parse-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!(value["data"]["generated_unix_seconds"], 42); + assert_eq!(value["data"]["summary"]["text_unit_count"], 7); + assert_eq!(value["data"]["textunit_queue_available"], true); + assert_eq!( + value["data"]["textunit_task_summary"]["queued_task_count"], + 1 + ); + assert_eq!(value["data"]["textunit_task_summary"]["text_unit_count"], 5); + assert_eq!(value["data"]["textunit_index_available"], true); + assert_eq!(value["data"]["textunit_index_summary"]["unit_count"], 2); + } + + fn write_textunit_index_fixture(current_dir: &Path) { + let index = bat_infrastructure::OfficialTextUnitIndex { + version: bat_infrastructure::OFFICIAL_TEXTUNIT_INDEX_VERSION, + generated_unix_seconds: 44, + resource_root: current_dir.to_path_buf(), + summary: bat_infrastructure::OfficialTextUnitIndexSummary { + unit_count: 2, + error_count: 1, + skipped_binary_text_asset_count: 0, + }, + units: vec![ + bat_infrastructure::OfficialTextUnitIndexUnit { + id: "direct:a#unit:0".to_string(), + parse_entry_key: "direct:a".to_string(), + source_url: "https://example.invalid/a.bundle".to_string(), + destination: "Bundle/a.bundle".to_string(), + archive_entry: None, + source_kind: bat_infrastructure::OfficialParseSourceKind::DirectBundle, + unity_version: Some("2021.3.56f2".to_string()), + source_text: "こんにちは".to_string(), + serialized_file: Some("CAB-a".to_string()), + path_id: Some(42), + class_id: Some(114), + field_path: Some("Scenario.Message".to_string()), + field_offset: Some(16), + field_byte_size: Some(20), + format: Some("plain".to_string()), + text_source_kind: Some("TypeTreeField".to_string()), + asset_name: None, + context: std::collections::BTreeMap::from([( + "source_kind".to_string(), + "TypeTreeField".to_string(), + )]), + }, + bat_infrastructure::OfficialTextUnitIndexUnit { + id: "direct:b#unit:0".to_string(), + parse_entry_key: "direct:b".to_string(), + source_url: "https://example.invalid/b.bundle".to_string(), + destination: "Bundle/b.bundle".to_string(), + archive_entry: Some("assets/b.bundle".to_string()), + source_kind: bat_infrastructure::OfficialParseSourceKind::ZipEntry, + unity_version: Some("2021.3.56f2".to_string()), + source_text: "{\"text\":\"hello\"}".to_string(), + serialized_file: Some("CAB-b".to_string()), + path_id: Some(7), + class_id: Some(49), + field_path: Some("TextAsset".to_string()), + field_offset: None, + field_byte_size: None, + format: Some("json".to_string()), + text_source_kind: Some("TextAsset".to_string()), + asset_name: Some("Scenario".to_string()), + context: std::collections::BTreeMap::from([ + ("source_kind".to_string(), "TextAsset".to_string()), + ("format".to_string(), "json".to_string()), + ]), + }, + ], + errors: vec![bat_infrastructure::OfficialTextUnitIndexError { + id: "direct:a#extract-error:0".to_string(), + parse_entry_key: "direct:a".to_string(), + source_url: "https://example.invalid/a.bundle".to_string(), + destination: "Bundle/a.bundle".to_string(), + archive_entry: None, + source_kind: bat_infrastructure::OfficialParseSourceKind::DirectBundle, + status: bat_infrastructure::OfficialParseStatus::Parsed, + serialized_file: Some("CAB-a".to_string()), + path_id: Some(42), + class_id: Some(114), + field_path: Some("Managed.Ref".to_string()), + offset: Some(64), + error: "managed reference TypeTree node is not decoded yet".to_string(), + }], + }; + bat_infrastructure::write_textunit_index_at(current_dir, &index).unwrap(); + } + + #[test] + fn dispatch_parse_text_units_filters_current_index() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + write_textunit_index_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request( + "parse.text_units", + Some(serde_json::json!({ + "path_pattern": "Bundle/*.bundle", + "path_id": 42, + "class_id": 114, + "field_path": "Scenario.Message", + "format": "plain", + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-text-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["total_entries"], 1); + assert_eq!(value["data"]["entries"][0]["source_text"], "こんにちは"); + assert_eq!(value["data"]["entries"][0]["class_id"], 114); + assert_eq!(value["data"]["entries"][0]["field_offset"], 16); + } + + #[test] + fn dispatch_parse_errors_filters_current_index() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + write_textunit_index_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request( + "parse.errors", + Some(serde_json::json!({ + "destination": "Bundle/a.bundle", + "path_id": "42", + "class_id": "114" + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-text-err-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["total_entries"], 1); + assert!(value["data"]["entries"][0]["error"] + .as_str() + .unwrap() + .contains("managed reference")); + assert_eq!(value["data"]["entries"][0]["offset"], 64); + } + + #[test] + fn dispatch_parse_status_reports_unavailable_without_cache() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + + let envelope = dispatch_rpc_method( + &rpc_request("parse.status", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-parse-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + assert_eq!(value["data"]["current_version_id"], "v-current"); + } + + #[cfg(unix)] + #[test] + fn dispatch_localized_status_verifies_current_release_pointer() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let localized_root = temp.path().join("localized"); + let localized_version = localized_root + .join(LOCALIZED_VERSIONS_DIR) + .join("v-current"); + fs::create_dir_all(&localized_version).unwrap(); + symlink( + Path::new(LOCALIZED_VERSIONS_DIR).join("v-current"), + localized_root.join(LOCALIZED_CURRENT_LINK), + ) + .unwrap(); + fs::write( + localized_root.join(LOCALIZED_VERSION_STATE_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { + state_version: 1, + official_release_id: "v-current".to_string(), + current_release_id: Some("v-current".to_string()), + status: "localized".to_string(), + updated_unix_seconds: 123, + }) + .unwrap(), + ) + .unwrap(); + fs::write( + localized_version.join(LOCALIZED_PATCH_MANIFEST_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest { + manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: "v-current".to_string(), + localized_release_id: "v-current".to_string(), + generated_unix_seconds: 124, + file_count: 0, + text_asset_operation_count: 0, + files: Vec::new(), + rollback: bat_infrastructure::LocalizedPatchRollbackInfo { + previous_current_target: None, + remove_version_path: localized_version.clone(), + }, + }) + .unwrap(), + ) + .unwrap(); + let tasks = test_task_context_with_config(OfficialUpdateConfig { + localized_output_root: localized_root.clone(), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request("localized.status", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-loc-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["status"], "localized"); + assert_eq!(value["data"]["official_current_version_id"], "v-current"); + assert_eq!(value["data"]["matches_current_official_release"], true); + assert_eq!(value["data"]["current_points_to_published_version"], true); + assert_eq!(value["data"]["patch_manifest_available"], true); + assert_eq!(value["data"]["patch_manifest_matches_release"], true); + assert_eq!(value["data"]["patch_file_count"], 0); + assert_eq!(value["data"]["patch_text_asset_operation_count"], 0); + assert_eq!( + value["data"]["published_version_path"].as_str().unwrap(), + localized_version.to_string_lossy() + ); + } + + #[cfg(unix)] + #[test] + fn dispatch_localized_status_rejects_stale_release_state() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let localized_root = temp.path().join("localized"); + let localized_version = localized_root.join(LOCALIZED_VERSIONS_DIR).join("v-old"); + fs::create_dir_all(&localized_version).unwrap(); + symlink( + Path::new(LOCALIZED_VERSIONS_DIR).join("v-old"), + localized_root.join(LOCALIZED_CURRENT_LINK), + ) + .unwrap(); + fs::write( + localized_root.join(LOCALIZED_VERSION_STATE_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { + state_version: 1, + official_release_id: "v-old".to_string(), + current_release_id: Some("v-old".to_string()), + status: "localized".to_string(), + updated_unix_seconds: 123, + }) + .unwrap(), + ) + .unwrap(); + let tasks = test_task_context_with_config(OfficialUpdateConfig { + localized_output_root: localized_root, + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request("localized.status", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-loc-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["status"], "not_localized"); + assert_eq!(value["data"]["matches_current_official_release"], false); + assert_eq!( + value["data"]["published_version_path"], + serde_json::Value::Null + ); + } + #[test] fn dispatch_daemon_clean_stable_reports_not_implemented() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/infrastructure/src/import.rs b/infrastructure/src/import.rs index b3e12f4..24d7129 100644 --- a/infrastructure/src/import.rs +++ b/infrastructure/src/import.rs @@ -2,9 +2,10 @@ use bat_adapters::manifest::GenericManifest; use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry}; -use bat_core::domain::{Resource, ResourceEntry, ResourceType}; +use bat_assetbundle::TextUnitExtractor; +use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType}; use bat_core::repositories::{CasRepository, ResourceRepository}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; /// 待导入的 AssetBundle 数据。 @@ -97,6 +98,12 @@ pub struct UnityFsImportSummary { pub text_assets: Vec, /// 非致命 serialized-file 解析诊断数量。 pub serialized_parse_error_count: usize, + /// 从 TextAsset 和 TypeTree 字段提取出的 TextUnit 数量。 + pub text_unit_count: usize, + /// TextUnit 格式标签。 + pub text_unit_formats: Vec, + /// TextUnit 提取阶段的非致命诊断数量。 + pub text_unit_error_count: usize, } /// Manifest 导入报告。 @@ -208,6 +215,7 @@ impl<'a> ResourceImportService<'a> { id: resource_id_for_path(&entry.path), local_path: PathBuf::from(&entry.path), entry: stored_entry, + metadata: ResourceMetadata::default(), }; let id = self.resources.add(resource).await?; added_resources.push(id.clone()); @@ -266,6 +274,14 @@ impl<'a> ResourceImportService<'a> { manifest_path, error )) })?; + let text_units = TextUnitExtractor::new().extract_bundle(&parsed, Some(manifest_path)); + let text_unit_formats = text_units + .units + .iter() + .filter_map(|unit| unit.context.get("format").cloned()) + .collect::>() + .into_iter() + .collect(); Ok(UnityFsImportSummary { unity_version: parsed.unity_version, @@ -285,6 +301,9 @@ impl<'a> ResourceImportService<'a> { .map(|asset| asset.name) .collect(), serialized_parse_error_count: parsed.serialized_parse_errors.len(), + text_unit_count: text_units.units.len(), + text_unit_formats, + text_unit_error_count: text_units.errors.len(), }) } } @@ -715,6 +734,9 @@ mod tests { assert_eq!(unityfs.text_asset_count, 0); assert!(unityfs.text_assets.is_empty()); assert_eq!(unityfs.serialized_parse_error_count, 0); + assert_eq!(unityfs.text_unit_count, 0); + assert!(unityfs.text_unit_formats.is_empty()); + assert_eq!(unityfs.text_unit_error_count, 0); assert_eq!( report.imported[1].category, ResourceImportCategory::TextAsset @@ -799,6 +821,9 @@ mod tests { assert_eq!(unityfs.text_asset_count, 1); assert_eq!(unityfs.text_assets, vec!["Scenario".to_string()]); assert_eq!(unityfs.serialized_parse_error_count, 0); + assert_eq!(unityfs.text_unit_count, 1); + assert_eq!(unityfs.text_unit_formats, vec!["plain".to_string()]); + assert_eq!(unityfs.text_unit_error_count, 0); } #[tokio::test] diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 78c4bda..f1f70b3 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -13,13 +13,18 @@ pub mod cas; mod curl_transfer; pub mod import; +pub mod localized_patch; +pub mod official_changes; 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_repository; pub mod official_sync; +pub mod official_textunit_queue; pub mod official_update; +pub mod patch_ops; pub mod path_security; pub mod resources; mod zip_validation; @@ -32,6 +37,24 @@ pub use import::{ BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport, ResourceImportService, }; +pub use localized_patch::{ + read_localized_patch_manifest_at, read_localized_version_state, LocalizedPatchConfig, + LocalizedPatchFile, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation, + LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService, + LocalizedTextAssetPatch, LocalizedVersionState, LOCALIZED_CURRENT_LINK, + LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR, + LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, +}; +pub use official_changes::{ + read_resource_change_set_at, write_crowdin_translation_handoff_at, + write_official_resource_change_handoff, write_resource_change_set_at, + CrowdinTranslationHandoff, OfficialResourceChange, OfficialResourceChangeHandoffReport, + OfficialResourceChangeKind, OfficialResourceChangeSet, OfficialResourceChangeSummary, + OfficialResourceDescriptor, TranslationHandoffProvider, TranslationHandoffResource, + TranslationHandoffStatus, CROWDIN_TRANSLATION_HANDOFF_FILE, + CROWDIN_TRANSLATION_HANDOFF_VERSION, OFFICIAL_RESOURCE_CHANGES_FILE, + OFFICIAL_RESOURCE_CHANGES_VERSION, +}; pub use official_download::{ read_download_manifest_at, DownloadError, OfficialDownloadManifest, OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem, @@ -47,20 +70,35 @@ pub use official_launcher::{ YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, }; pub use official_parse::{ - read_parse_cache_at, write_parse_cache_at, OfficialParseCache, OfficialParseCacheEntry, - OfficialParseCacheService, OfficialParseConfig, OfficialParseReport, + query_textunit_index_errors, query_textunit_index_units, read_parse_cache_at, + read_textunit_index_at, write_parse_cache_at, write_textunit_index_at, OfficialParseCache, + OfficialParseCacheEntry, OfficialParseCacheService, OfficialParseConfig, OfficialParseReport, OfficialParseSourceFingerprint, OfficialParseSourceKind, OfficialParseStatus, - OfficialParseSummary, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_PARSE_CACHE_VERSION, + OfficialParseSummary, OfficialTextUnitIndex, OfficialTextUnitIndexError, + OfficialTextUnitIndexSummary, OfficialTextUnitIndexUnit, OfficialTextUnitQuery, + OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_PARSE_CACHE_VERSION, OFFICIAL_TEXTUNIT_INDEX_FILE, + OFFICIAL_TEXTUNIT_INDEX_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, OfficialResourcePullPlan, }; +pub use official_repository::{ + OfficialReleaseImportConfig, OfficialReleaseImportReport, OfficialReleaseImportService, +}; pub use official_sync::{ build_official_sync_plan, changed_endpoint_urls, classify_sync_decision, default_official_platforms, OfficialSyncDecision, OfficialSyncPlan, }; +pub use official_textunit_queue::{ + read_textunit_task_queue_at, write_crowdin_textunit_queue_at, write_official_textunit_queues, + write_textunit_task_queue_at, CrowdinTextUnitQueue, CrowdinTextUnitQueueItem, + OfficialTextUnitQueueReport, OfficialTextUnitTask, OfficialTextUnitTaskQueue, + OfficialTextUnitTaskStatus, OfficialTextUnitTaskSummary, CROWDIN_TEXTUNIT_QUEUE_FILE, + CROWDIN_TEXTUNIT_QUEUE_VERSION, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, + OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, +}; 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, @@ -71,6 +109,12 @@ pub use official_update::{ OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap, }; +pub use patch_ops::{ + apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, + apply_unityfs_text_asset_patch_file, PatchApplyKind, PatchApplyParams, PatchApplyReport, + UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, + UnityFsTextAssetPatchParams, +}; pub use path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute, open_append_file, read_file_no_symlink, set_file_mode, validate_output_root, diff --git a/infrastructure/src/localized_patch.rs b/infrastructure/src/localized_patch.rs new file mode 100644 index 0000000..db3bc50 --- /dev/null +++ b/infrastructure/src/localized_patch.rs @@ -0,0 +1,857 @@ +//! Localized release publishing for verified TextAsset patches. + +use bat_assetbundle::{patch_unityfs_text_asset, TextAssetPatch}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::path_security::{ + ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute, + read_file_no_symlink, write_file_atomic, STATE_FILE_MODE, +}; + +/// Atomic current pointer under the localized output root. +pub const LOCALIZED_CURRENT_LINK: &str = "current"; +/// Staging directory under the localized output root. +pub const LOCALIZED_STAGING_DIR: &str = ".staging"; +/// Version directory under the localized output root. +pub const LOCALIZED_VERSIONS_DIR: &str = "versions"; +/// Persisted localized release state file name. +pub const LOCALIZED_VERSION_STATE_FILE: &str = "localized-version-state.json"; +/// Per-release patch manifest file name. +pub const LOCALIZED_PATCH_MANIFEST_FILE: &str = "localized-patch-manifest.json"; +/// Current localized patch manifest schema version. +pub const LOCALIZED_PATCH_MANIFEST_VERSION: u32 = 1; + +/// One patch operation against a bundle in an official release. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalizedTextAssetPatch { + /// Relative path of the UnityFS bundle under the official release. + pub bundle_path: String, + /// TextAsset replacement inside the bundle. + pub text_asset: TextAssetPatch, +} + +/// Configuration for one localized release publication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalizedPatchConfig { + /// Immutable, verified official release root. + pub official_release_root: PathBuf, + /// Separate localized publication root. + pub localized_output_root: PathBuf, + /// Version identifier shared with the official release. + pub release_id: String, + /// Patch operations to apply. + pub patches: Vec, +} + +impl LocalizedPatchConfig { + /// Creates a localized patch configuration. + pub fn new( + official_release_root: impl Into, + localized_output_root: impl Into, + release_id: impl Into, + patches: Vec, + ) -> Self { + Self { + official_release_root: official_release_root.into(), + localized_output_root: localized_output_root.into(), + release_id: release_id.into(), + patches, + } + } +} + +/// Persisted localized release state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedVersionState { + /// State schema version. + pub state_version: u32, + /// Official release ID used as the patch source. + pub official_release_id: String, + /// Published localized release ID. + pub current_release_id: Option, + /// Stable status label. + pub status: String, + /// Last update time. + pub updated_unix_seconds: u64, +} + +/// One changed file in a localized patch manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedPatchFile { + /// Relative bundle path. + pub path: String, + /// BLAKE3 before applying the patch. + pub original_blake3: String, + /// BLAKE3 after applying the patch. + pub localized_blake3: String, + /// Original file size in bytes. + #[serde(default)] + pub original_bytes: u64, + /// Localized file size in bytes. + #[serde(default)] + pub localized_bytes: u64, + /// Localized minus original byte size. + #[serde(default)] + pub byte_delta: i64, + /// TextAsset operations applied to this file. + #[serde(default)] + pub text_asset_operations: Vec, +} + +/// One TextAsset patch operation recorded in the localized patch manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedPatchOperation { + /// UnityFS directory path of the serialized file. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Expected TextAsset name, when provided. + pub expected_name: Option, + /// Replacement payload size. + pub replacement_bytes: u64, + /// BLAKE3 of the replacement payload. + pub replacement_blake3: String, +} + +/// Rollback information recorded for a localized publication. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedPatchRollbackInfo { + /// Previous `current` symlink target before this publication. + pub previous_current_target: Option, + /// Version directory that should be removed when rolling this publication back. + pub remove_version_path: PathBuf, +} + +/// Integrity summary for a published localized release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedPatchIntegrity { + /// Number of changed files verified against the manifest. + pub verified_changed_file_count: usize, + /// Number of TextAsset operations recorded in the manifest. + pub verified_text_asset_operation_count: usize, + /// Whether `current` points at this localized release. + pub current_points_to_release: bool, +} + +/// Persisted manifest for one localized release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedPatchManifest { + /// Manifest schema version. + #[serde(default = "default_patch_manifest_version")] + pub manifest_version: u32, + /// Official release ID used as the patch source. + pub official_release_id: String, + /// Published localized release ID. + pub localized_release_id: String, + /// Manifest generation time as Unix seconds. + pub generated_unix_seconds: u64, + /// Changed file count. + pub file_count: usize, + /// TextAsset operation count. + pub text_asset_operation_count: usize, + /// Changed files and their before/after hashes. + pub files: Vec, + /// Rollback information for this release. + pub rollback: LocalizedPatchRollbackInfo, +} + +impl LocalizedPatchManifest { + /// Converts the localized TextAsset manifest to the generic patch manifest model. + pub fn to_patch_manifest(&self) -> bat_patch::PatchManifest { + bat_patch::PatchManifest { + version: bat_patch::PATCH_MANIFEST_VERSION, + patch_id: self.localized_release_id.clone(), + source_version: self.official_release_id.clone(), + target_version: self.localized_release_id.clone(), + files: self + .files + .iter() + .map(|file| bat_patch::PatchManifestFile { + path: PathBuf::from(&file.path), + patch_kind: bat_patch::PatchKind::UnityFsTextAsset, + source_blake3: file.original_blake3.clone(), + target_blake3: file.localized_blake3.clone(), + source_size: file.original_bytes, + target_size: file.localized_bytes, + }) + .collect(), + rollback: bat_patch::PatchRollback { + previous_current_target: self.rollback.previous_current_target.clone(), + remove_target_path: Some(self.rollback.remove_version_path.clone()), + }, + } + } +} + +/// Result of a successful localized release publication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalizedPatchReport { + /// Published version directory. + pub version_path: PathBuf, + /// Atomic current pointer. + pub current_path: PathBuf, + /// Version state path. + pub state_path: PathBuf, + /// Patch manifest path. + pub patch_manifest_path: PathBuf, + /// Changed files. + pub files: Vec, + /// Persisted patch manifest. + pub manifest: LocalizedPatchManifest, + /// Integrity check performed after publication. + pub integrity: LocalizedPatchIntegrity, +} + +/// Applies TextAsset patches and atomically publishes a localized release. +#[derive(Debug, Default, Clone, Copy)] +pub struct LocalizedPatchService; + +impl LocalizedPatchService { + /// Creates the publisher. + pub fn new() -> Self { + Self + } + + /// Copies the official release, applies patches in staging and publishes it. + pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result { + let staging = config + .localized_output_root + .join(LOCALIZED_STAGING_DIR) + .join(&config.release_id); + let version_path = config + .localized_output_root + .join(LOCALIZED_VERSIONS_DIR) + .join(&config.release_id); + let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); + let previous_current_target = current_symlink_target(¤t_path).ok().flatten(); + let version_existed_before = version_path.exists(); + match self.publish_inner(config, previous_current_target.clone()) { + Ok(report) => Ok(report), + Err(error) => { + if let Err(rollback_error) = rollback_failed_publish( + &config.localized_output_root, + &staging, + &version_path, + !version_existed_before, + ¤t_path, + previous_current_target.as_ref(), + ) { + return Err(anyhow::anyhow!( + "{error}; rollback failed: {rollback_error}" + )); + } + Err(error) + } + } + } + + fn publish_inner( + &self, + config: &LocalizedPatchConfig, + previous_current_target: Option, + ) -> anyhow::Result { + validate_config(config).map_err(anyhow::Error::msg)?; + let staging = config + .localized_output_root + .join(LOCALIZED_STAGING_DIR) + .join(&config.release_id); + let version_path = config + .localized_output_root + .join(LOCALIZED_VERSIONS_DIR) + .join(&config.release_id); + let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); + let state_path = config + .localized_output_root + .join(LOCALIZED_VERSION_STATE_FILE); + let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE); + + if version_path.exists() { + return Err(anyhow::anyhow!( + "localized release already exists: {}", + version_path.display() + )); + } + remove_owned_staging(&staging)?; + fs::create_dir_all(&staging)?; + copy_tree(&config.official_release_root, &staging)?; + + let mut changed_files = Vec::with_capacity(config.patches.len()); + for operation in &config.patches { + let target = staging.join(Path::new(&operation.bundle_path)); + ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?; + ensure_safe_file_target(&staging, &target, "汉化 patch 输入") + .map_err(anyhow::Error::msg)?; + let original = fs::read(&target)?; + let patched = patch_unityfs_text_asset(&original, &operation.text_asset) + .map_err(|error| anyhow::anyhow!("{}: {error}", operation.bundle_path))?; + if original == patched { + return Err(anyhow::anyhow!( + "patch produced no change: {}", + operation.bundle_path + )); + } + write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出") + .map_err(anyhow::Error::msg)?; + let original_blake3 = blake3::hash(&original).to_hex().to_string(); + let localized_blake3 = blake3::hash(&patched).to_hex().to_string(); + changed_files.push(LocalizedPatchFile { + path: operation.bundle_path.clone(), + original_blake3, + localized_blake3, + original_bytes: original.len() as u64, + localized_bytes: patched.len() as u64, + byte_delta: patched.len() as i64 - original.len() as i64, + text_asset_operations: vec![LocalizedPatchOperation::from_text_asset_patch( + &operation.text_asset, + )], + }); + } + + let manifest = LocalizedPatchManifest { + manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: config.release_id.clone(), + localized_release_id: config.release_id.clone(), + generated_unix_seconds: unix_seconds_now(), + file_count: changed_files.len(), + text_asset_operation_count: changed_files + .iter() + .map(|file| file.text_asset_operations.len()) + .sum(), + files: changed_files.clone(), + rollback: LocalizedPatchRollbackInfo { + previous_current_target: previous_current_target.clone(), + remove_version_path: version_path.clone(), + }, + }; + write_file_atomic( + &staging.join(LOCALIZED_PATCH_MANIFEST_FILE), + &serde_json::to_vec_pretty(&manifest)?, + STATE_FILE_MODE, + "汉化 patch manifest", + ) + .map_err(anyhow::Error::msg)?; + verify_patch_manifest_files(&config.official_release_root, &staging, &manifest)?; + fs::create_dir_all(config.localized_output_root.join(LOCALIZED_VERSIONS_DIR))?; + fs::rename(&staging, &version_path)?; + switch_current_symlink( + &config.localized_output_root, + ¤t_path, + &config.release_id, + )?; + + let state = LocalizedVersionState { + state_version: 1, + official_release_id: config.release_id.clone(), + current_release_id: Some(config.release_id.clone()), + status: "localized".to_string(), + updated_unix_seconds: unix_seconds_now(), + }; + write_file_atomic( + &state_path, + &serde_json::to_vec_pretty(&state)?, + STATE_FILE_MODE, + "汉化版本状态", + ) + .map_err(anyhow::Error::msg)?; + let integrity = verify_published_localized_release( + &config.official_release_root, + &version_path, + ¤t_path, + )?; + + Ok(LocalizedPatchReport { + version_path, + current_path, + state_path, + patch_manifest_path, + files: changed_files, + manifest, + integrity, + }) + } +} + +/// Reads the localized release state without following a symlink at the file +/// path. A missing state file means no localized release has been published. +pub fn read_localized_version_state( + localized_output_root: &Path, +) -> anyhow::Result> { + let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE); + let Some(bytes) = read_file_no_symlink(&path, "汉化版本状态").map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let state: LocalizedVersionState = serde_json::from_slice(&bytes)?; + if state.state_version != 1 { + return Err(anyhow::anyhow!( + "不支持的汉化版本状态 schema:{},当前版本=1", + state.state_version + )); + } + Ok(Some(state)) +} + +/// Reads a localized patch manifest from a published version directory. +pub fn read_localized_patch_manifest_at( + version_path: &Path, +) -> anyhow::Result> { + let path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE); + let Some(bytes) = + read_file_no_symlink(&path, "汉化 patch manifest").map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let manifest: LocalizedPatchManifest = serde_json::from_slice(&bytes)?; + if manifest.manifest_version != LOCALIZED_PATCH_MANIFEST_VERSION { + return Err(anyhow::anyhow!( + "不支持的汉化 patch manifest schema:{},当前版本={}", + manifest.manifest_version, + LOCALIZED_PATCH_MANIFEST_VERSION + )); + } + Ok(Some(manifest)) +} + +impl LocalizedPatchOperation { + fn from_text_asset_patch(patch: &TextAssetPatch) -> Self { + Self { + serialized_file_path: patch.serialized_file_path.clone(), + path_id: patch.path_id, + expected_name: patch.expected_name.clone(), + replacement_bytes: patch.replacement.len() as u64, + replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(), + } + } +} + +fn verify_published_localized_release( + official_release_root: &Path, + version_path: &Path, + current_path: &Path, +) -> anyhow::Result { + let manifest = read_localized_patch_manifest_at(version_path)?.ok_or_else(|| { + anyhow::anyhow!( + "缺少汉化 patch manifest:{}", + version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display() + ) + })?; + let mut integrity = + verify_patch_manifest_files(official_release_root, version_path, &manifest)?; + integrity.current_points_to_release = current_points_to_version(current_path, version_path)?; + if !integrity.current_points_to_release { + return Err(anyhow::anyhow!( + "汉化 current 未指向发布版本:current={} version={}", + current_path.display(), + version_path.display() + )); + } + Ok(integrity) +} + +fn verify_patch_manifest_files( + official_release_root: &Path, + localized_release_root: &Path, + manifest: &LocalizedPatchManifest, +) -> anyhow::Result { + let mut operation_count = 0usize; + for file in &manifest.files { + let relative = Path::new(&file.path); + let official_path = official_release_root.join(relative); + let localized_path = localized_release_root.join(relative); + ensure_path_within_root(official_release_root, &official_path) + .map_err(anyhow::Error::msg)?; + ensure_path_within_root(localized_release_root, &localized_path) + .map_err(anyhow::Error::msg)?; + ensure_safe_file_target(official_release_root, &official_path, "官方 patch 原文件") + .map_err(anyhow::Error::msg)?; + ensure_safe_file_target(localized_release_root, &localized_path, "汉化 patch 产物") + .map_err(anyhow::Error::msg)?; + let original = fs::read(&official_path)?; + let localized = fs::read(&localized_path)?; + let original_hash = blake3::hash(&original).to_hex().to_string(); + let localized_hash = blake3::hash(&localized).to_hex().to_string(); + if original_hash != file.original_blake3 || original.len() as u64 != file.original_bytes { + return Err(anyhow::anyhow!( + "汉化 manifest 原文件校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}", + file.path, + file.original_blake3, + file.original_bytes, + original_hash, + original.len() + )); + } + if localized_hash != file.localized_blake3 || localized.len() as u64 != file.localized_bytes + { + return Err(anyhow::anyhow!( + "汉化 manifest 产物校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}", + file.path, + file.localized_blake3, + file.localized_bytes, + localized_hash, + localized.len() + )); + } + if localized_hash == original_hash { + return Err(anyhow::anyhow!( + "汉化 manifest 文件未发生变化:{}", + file.path + )); + } + operation_count += file.text_asset_operations.len(); + } + if manifest.file_count != manifest.files.len() { + return Err(anyhow::anyhow!( + "汉化 manifest file_count 不一致:声明 {},实际 {}", + manifest.file_count, + manifest.files.len() + )); + } + if manifest.text_asset_operation_count != operation_count { + return Err(anyhow::anyhow!( + "汉化 manifest operation_count 不一致:声明 {},实际 {}", + manifest.text_asset_operation_count, + operation_count + )); + } + Ok(LocalizedPatchIntegrity { + verified_changed_file_count: manifest.files.len(), + verified_text_asset_operation_count: operation_count, + current_points_to_release: false, + }) +} + +fn current_symlink_target(current_path: &Path) -> anyhow::Result> { + match fs::symlink_metadata(current_path) { + Ok(metadata) if metadata.file_type().is_symlink() => Ok(Some(fs::read_link(current_path)?)), + Ok(_) => Err(anyhow::anyhow!( + "汉化 current 已存在但不是 symlink:{}", + current_path.display() + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result { + let metadata = fs::symlink_metadata(current_path)?; + if !metadata.file_type().is_symlink() { + return Ok(false); + } + Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?) +} + +fn rollback_failed_publish( + localized_output_root: &Path, + staging: &Path, + version_path: &Path, + remove_version_path: bool, + current_path: &Path, + previous_current_target: Option<&PathBuf>, +) -> anyhow::Result<()> { + remove_owned_path(staging)?; + if remove_version_path { + remove_owned_path(version_path)?; + } + remove_owned_path(&localized_output_root.join(".current.tmp"))?; + remove_owned_path(&localized_output_root.join(".current.rollback.tmp"))?; + let failed_target = version_path + .file_name() + .map(|release_id| Path::new(LOCALIZED_VERSIONS_DIR).join(release_id)); + if failed_target.as_ref().is_some_and(|target| { + fs::read_link(current_path) + .map(|current_target| current_target == *target) + .unwrap_or(false) + }) { + restore_current_symlink(localized_output_root, current_path, previous_current_target)?; + } + Ok(()) +} + +fn remove_owned_path(path: &Path) -> anyhow::Result<()> { + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || metadata.is_file() { + fs::remove_file(path)?; + } else if metadata.is_dir() { + fs::remove_dir_all(path)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn restore_current_symlink( + root: &Path, + current_path: &Path, + previous_current_target: Option<&PathBuf>, +) -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + if let Some(previous_target) = previous_current_target { + let temporary = root.join(".current.rollback.tmp"); + remove_owned_path(&temporary)?; + symlink(previous_target, &temporary)?; + fs::rename(temporary, current_path)?; + } else if fs::symlink_metadata(current_path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + fs::remove_file(current_path)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn restore_current_symlink( + _root: &Path, + _current_path: &Path, + _previous_current_target: Option<&PathBuf>, +) -> anyhow::Result<()> { + Ok(()) +} + +fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> { + let official = lexical_absolute(&config.official_release_root)?; + let localized = lexical_absolute(&config.localized_output_root)?; + if official == localized || official.starts_with(&localized) || localized.starts_with(&official) + { + return Err(format!( + "官方 release 与汉化输出目录不能相同或互相嵌套:官方={} 汉化={}", + official.display(), + localized.display() + )); + } + if config.release_id.is_empty() + || config.release_id.contains('/') + || config.release_id.contains('\\') + || config.release_id == "." + || config.release_id == ".." + { + return Err(format!("非法汉化 release id:{}", config.release_id)); + } + ensure_safe_directory_path(&config.official_release_root, "官方 release")?; + ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?; + Ok(()) +} + +fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + return Err(anyhow::anyhow!( + "官方 release 不能包含 symlink: {}", + source.display() + )); + } + if metadata.is_dir() { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree(&entry.path(), &destination.join(entry.file_name()))?; + } + } else if metadata.is_file() { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(source, destination)?; + } else { + return Err(anyhow::anyhow!( + "官方 release 中存在非普通文件: {}", + source.display() + )); + } + Ok(()) +} + +fn remove_owned_staging(path: &Path) -> anyhow::Result<()> { + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() { + return Err(anyhow::anyhow!( + "汉化 staging 不能是 symlink: {}", + path.display() + )); + } + if metadata.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn switch_current_symlink(root: &Path, current: &Path, release_id: &str) -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let temporary = root.join(".current.tmp"); + if let Ok(metadata) = fs::symlink_metadata(&temporary) { + if metadata.file_type().is_symlink() || metadata.is_file() { + fs::remove_file(&temporary)?; + } else if metadata.is_dir() { + fs::remove_dir_all(&temporary)?; + } + } + symlink( + Path::new(LOCALIZED_VERSIONS_DIR).join(release_id), + &temporary, + )?; + fs::rename(temporary, current)?; + Ok(()) +} + +#[cfg(not(unix))] +fn switch_current_symlink(_root: &Path, _current: &Path, _release_id: &str) -> anyhow::Result<()> { + Err(anyhow::anyhow!( + "localized release publication requires a Unix symlink-capable platform" + )) +} + +fn unix_seconds_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn default_patch_manifest_version() -> u32 { + LOCALIZED_PATCH_MANIFEST_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn localized_manifest_converts_to_generic_patch_manifest() { + let source = b"source"; + let target = b"target"; + let manifest = LocalizedPatchManifest { + manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: "official-v1".to_string(), + localized_release_id: "localized-v1".to_string(), + generated_unix_seconds: 123, + file_count: 1, + text_asset_operation_count: 1, + files: vec![LocalizedPatchFile { + path: "Bundles/file.bundle".to_string(), + original_blake3: blake3::hash(source).to_hex().to_string(), + localized_blake3: blake3::hash(target).to_hex().to_string(), + original_bytes: source.len() as u64, + localized_bytes: target.len() as u64, + byte_delta: target.len() as i64 - source.len() as i64, + text_asset_operations: vec![LocalizedPatchOperation { + serialized_file_path: "CAB-asset".to_string(), + path_id: 1, + expected_name: Some("Text".to_string()), + replacement_bytes: target.len() as u64, + replacement_blake3: blake3::hash(target).to_hex().to_string(), + }], + }], + rollback: LocalizedPatchRollbackInfo { + previous_current_target: Some(PathBuf::from("versions/previous")), + remove_version_path: PathBuf::from("versions/localized-v1"), + }, + }; + + let patch_manifest = manifest.to_patch_manifest(); + + assert_eq!(patch_manifest.version, bat_patch::PATCH_MANIFEST_VERSION); + assert_eq!(patch_manifest.patch_id, "localized-v1"); + assert_eq!(patch_manifest.source_version, "official-v1"); + assert_eq!(patch_manifest.target_version, "localized-v1"); + assert_eq!(patch_manifest.files.len(), 1); + assert_eq!( + patch_manifest.files[0].patch_kind, + bat_patch::PatchKind::UnityFsTextAsset + ); + assert_eq!( + patch_manifest.rollback.previous_current_target, + Some(PathBuf::from("versions/previous")) + ); + assert_eq!( + patch_manifest.rollback.remove_target_path, + Some(PathBuf::from("versions/localized-v1")) + ); + } + + #[cfg(unix)] + #[test] + fn publishes_a_separate_localized_release_atomically() { + let temp = TempDir::new().unwrap(); + let official = temp.path().join("official-release"); + let localized = temp.path().join("localized"); + fs::create_dir_all(official.join("TableBundles")).unwrap(); + fs::write( + official.join("TableBundles/TableCatalog.bytes"), + b"official", + ) + .unwrap(); + + let report = LocalizedPatchService::new() + .publish(&LocalizedPatchConfig::new( + &official, + &localized, + "release-1", + Vec::new(), + )) + .unwrap(); + + assert_eq!( + fs::read(report.version_path.join("TableBundles/TableCatalog.bytes")).unwrap(), + b"official" + ); + assert_eq!( + fs::read_link(report.current_path).unwrap(), + PathBuf::from("versions/release-1") + ); + let state: LocalizedVersionState = + serde_json::from_slice(&fs::read(report.state_path).unwrap()).unwrap(); + assert_eq!(state.status, "localized"); + assert_eq!(state.current_release_id.as_deref(), Some("release-1")); + assert!(report.patch_manifest_path.is_file()); + assert_eq!(report.manifest.file_count, 0); + assert_eq!(report.integrity.verified_changed_file_count, 0); + assert!(report.integrity.current_points_to_release); + let manifest = read_localized_patch_manifest_at(&report.version_path) + .unwrap() + .unwrap(); + assert_eq!(manifest.localized_release_id, "release-1"); + assert_eq!(manifest.rollback.previous_current_target, None); + } + + #[cfg(unix)] + #[test] + fn failed_patch_publish_cleans_staging_and_unpublished_version() { + let temp = TempDir::new().unwrap(); + let official = temp.path().join("official-release"); + let localized = temp.path().join("localized"); + fs::create_dir_all(official.join("Bundles")).unwrap(); + fs::write(official.join("Bundles/bad.bundle"), b"not-unityfs").unwrap(); + + let error = LocalizedPatchService::new() + .publish(&LocalizedPatchConfig::new( + &official, + &localized, + "release-1", + vec![LocalizedTextAssetPatch { + bundle_path: "Bundles/bad.bundle".to_string(), + text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()), + }], + )) + .unwrap_err(); + + assert!(error.to_string().contains("Bundles/bad.bundle")); + assert!(!localized + .join(LOCALIZED_STAGING_DIR) + .join("release-1") + .exists()); + assert!(!localized + .join(LOCALIZED_VERSIONS_DIR) + .join("release-1") + .exists()); + assert!(!localized.join(LOCALIZED_CURRENT_LINK).exists()); + } +} diff --git a/infrastructure/src/official_changes.rs b/infrastructure/src/official_changes.rs new file mode 100644 index 0000000..1e1a647 --- /dev/null +++ b/infrastructure/src/official_changes.rs @@ -0,0 +1,653 @@ +//! Official resource change sets and translation handoff files. +//! +//! This module is intentionally file-based. Official update generates the +//! durable change set after a new release has been fully downloaded and +//! verified; parser and translation modules can then consume the same immutable +//! handoff without depending on daemon internals. + +use crate::official_download::{ + read_download_manifest_at, OfficialDownloadManifest, OfficialDownloadManifestEntry, +}; +use crate::path_security::{ + ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic, + STATE_FILE_MODE, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Current resource-change-set schema version. +pub const OFFICIAL_RESOURCE_CHANGES_VERSION: u32 = 1; +/// File name stored under a published official release root. +pub const OFFICIAL_RESOURCE_CHANGES_FILE: &str = "official-resource-changes.json"; +/// Current Crowdin handoff schema version. +pub const CROWDIN_TRANSLATION_HANDOFF_VERSION: u32 = 1; +/// File name stored under a published official release root. +pub const CROWDIN_TRANSLATION_HANDOFF_FILE: &str = "crowdin-translation-handoff.json"; + +/// Change kind for one official resource destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OfficialResourceChangeKind { + /// Destination did not exist in the previous complete release. + Added, + /// Destination exists in both releases, but verified size or BLAKE3 changed. + Modified, + /// Destination existed in the previous release but is absent from the new one. + Removed, +} + +impl OfficialResourceChangeKind { + /// Returns the stable JSON/RPC label for the change kind. + pub fn as_str(self) -> &'static str { + match self { + Self::Added => "added", + Self::Modified => "modified", + Self::Removed => "removed", + } + } + + /// Returns true when the changed resource should be offered to parser and + /// translation modules. + pub fn is_incremental_candidate(self) -> bool { + matches!(self, Self::Added | Self::Modified) + } +} + +/// Verified manifest attributes for one official resource. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialResourceDescriptor { + /// Official URL from the download manifest. + pub url: String, + /// Relative path under the official release root. + pub destination: String, + /// Verified byte count from the download manifest. + pub bytes: u64, + /// Verified BLAKE3 digest from the download manifest. + pub blake3: String, +} + +impl From<&OfficialDownloadManifestEntry> for OfficialResourceDescriptor { + fn from(entry: &OfficialDownloadManifestEntry) -> Self { + Self { + url: entry.url.clone(), + destination: entry.destination.clone(), + bytes: entry.bytes, + blake3: entry.blake3.clone(), + } + } +} + +/// One resource-level change between two complete official releases. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialResourceChange { + /// Relative path used as the stable comparison key. + pub destination: String, + /// Change kind for this destination. + pub kind: OfficialResourceChangeKind, + /// Previous release descriptor, when the destination existed before. + pub previous: Option, + /// Current release descriptor, when the destination exists now. + pub current: Option, + /// Whether parser modules should inspect this resource in incremental mode. + pub parse_candidate: bool, + /// Whether translation modules should enqueue this resource in incremental mode. + pub translation_candidate: bool, +} + +impl OfficialResourceChange { + fn new( + destination: String, + kind: OfficialResourceChangeKind, + previous: Option, + current: Option, + ) -> Self { + let is_candidate = kind.is_incremental_candidate(); + Self { + destination, + kind, + previous, + current, + parse_candidate: is_candidate, + translation_candidate: is_candidate, + } + } +} + +/// Aggregate counters for one official resource change set. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialResourceChangeSummary { + /// Whether a previous complete release manifest was available. + pub previous_manifest_present: bool, + /// Number of entries in the previous release manifest. + pub previous_manifest_entry_count: usize, + /// Number of entries in the current release manifest. + pub current_manifest_entry_count: usize, + /// Number of newly added destinations. + pub added_count: usize, + /// Number of destinations whose verified bytes or BLAKE3 changed. + pub modified_count: usize, + /// Number of destinations removed from the current release. + pub removed_count: usize, + /// Number of resources to offer to parser modules. + pub parse_candidate_count: usize, + /// Number of resources to offer to translation modules. + pub translation_candidate_count: usize, +} + +/// Durable comparison result between a previous complete official release and +/// the newly published official release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialResourceChangeSet { + /// Change-set schema version. + #[serde(default = "default_resource_changes_version")] + pub change_set_version: u32, + /// Current official release ID. + pub official_release_id: String, + /// Previous official release ID, when known. + pub previous_release_id: Option, + /// Generation time as Unix seconds. + pub generated_unix_seconds: u64, + /// Previous complete release root, when available. + pub previous_resource_root: Option, + /// Current complete release root. + pub current_resource_root: PathBuf, + /// Aggregate counters. + pub summary: OfficialResourceChangeSummary, + /// Stable, destination-sorted list of changed resources. + pub changes: Vec, +} + +impl OfficialResourceChangeSet { + /// Builds a change set from already loaded manifests. + pub fn from_manifests( + official_release_id: impl Into, + previous_release_id: Option, + previous_resource_root: Option, + current_resource_root: PathBuf, + previous_manifest: Option<&OfficialDownloadManifest>, + current_manifest: &OfficialDownloadManifest, + ) -> Self { + let previous_by_destination = previous_manifest + .map(entries_by_destination) + .unwrap_or_default(); + let current_by_destination = entries_by_destination(current_manifest); + let destinations = previous_by_destination + .keys() + .chain(current_by_destination.keys()) + .cloned() + .collect::>(); + + let mut changes = Vec::new(); + let mut summary = OfficialResourceChangeSummary { + previous_manifest_present: previous_manifest.is_some(), + previous_manifest_entry_count: previous_manifest + .map(|manifest| manifest.entries.len()) + .unwrap_or(0), + current_manifest_entry_count: current_manifest.entries.len(), + ..OfficialResourceChangeSummary::default() + }; + + for destination in destinations { + match ( + previous_by_destination.get(&destination), + current_by_destination.get(&destination), + ) { + (None, Some(current)) => { + summary.added_count += 1; + changes.push(OfficialResourceChange::new( + destination, + OfficialResourceChangeKind::Added, + None, + Some((*current).into()), + )); + } + (Some(previous), Some(current)) if content_changed(previous, current) => { + summary.modified_count += 1; + changes.push(OfficialResourceChange::new( + destination, + OfficialResourceChangeKind::Modified, + Some((*previous).into()), + Some((*current).into()), + )); + } + (Some(previous), None) => { + summary.removed_count += 1; + changes.push(OfficialResourceChange::new( + destination, + OfficialResourceChangeKind::Removed, + Some((*previous).into()), + None, + )); + } + _ => {} + } + } + + summary.parse_candidate_count = changes + .iter() + .filter(|change| change.parse_candidate) + .count(); + summary.translation_candidate_count = changes + .iter() + .filter(|change| change.translation_candidate) + .count(); + + Self { + change_set_version: OFFICIAL_RESOURCE_CHANGES_VERSION, + official_release_id: official_release_id.into(), + previous_release_id, + generated_unix_seconds: unix_seconds_now(), + previous_resource_root, + current_resource_root, + summary, + changes, + } + } + + /// Returns the resources that parser modules should inspect for + /// incremental work. + pub fn parse_candidates(&self) -> Vec<&OfficialResourceChange> { + self.changes + .iter() + .filter(|change| change.parse_candidate) + .collect() + } + + /// Returns the resources that translation modules should enqueue. + pub fn translation_candidates(&self) -> Vec<&OfficialResourceChange> { + self.changes + .iter() + .filter(|change| change.translation_candidate) + .collect() + } +} + +/// Provider reserved for translation handoff consumers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranslationHandoffProvider { + /// Crowdin provider. The handoff file does not make a network request. + Crowdin, +} + +impl TranslationHandoffProvider { + /// Returns the stable provider label. + pub fn as_str(self) -> &'static str { + match self { + Self::Crowdin => "crowdin", + } + } +} + +/// Status of a generated translation handoff. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranslationHandoffStatus { + /// The handoff was written locally and is waiting for a translation worker. + QueuedOffline, +} + +impl TranslationHandoffStatus { + /// Returns the stable status label. + pub fn as_str(self) -> &'static str { + match self { + Self::QueuedOffline => "queued_offline", + } + } +} + +/// One resource entry queued for translation-provider processing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranslationHandoffResource { + /// Relative path under the official release root. + pub destination: String, + /// Change kind that caused this resource to be queued. + pub kind: OfficialResourceChangeKind, + /// Current official URL. + pub url: String, + /// Verified byte count. + pub bytes: u64, + /// Verified BLAKE3 digest. + pub blake3: String, +} + +/// Crowdin-ready local queue file for added or modified official resources. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrowdinTranslationHandoff { + /// Handoff schema version. + #[serde(default = "default_crowdin_handoff_version")] + pub handoff_version: u32, + /// Translation provider reserved for this queue. + pub provider: TranslationHandoffProvider, + /// Current queue status. + pub status: TranslationHandoffStatus, + /// Current official release ID. + pub official_release_id: String, + /// Previous official release ID, when known. + pub previous_release_id: Option, + /// Generation time as Unix seconds. + pub generated_unix_seconds: u64, + /// Number of queued resources. + pub resource_count: usize, + /// Added or modified resources to pass into parsing/translation workers. + pub resources: Vec, +} + +impl CrowdinTranslationHandoff { + /// Builds a local Crowdin handoff from a resource change set. + pub fn from_change_set(change_set: &OfficialResourceChangeSet) -> Self { + let resources = change_set + .translation_candidates() + .into_iter() + .filter_map(|change| { + let current = change.current.as_ref()?; + Some(TranslationHandoffResource { + destination: change.destination.clone(), + kind: change.kind, + url: current.url.clone(), + bytes: current.bytes, + blake3: current.blake3.clone(), + }) + }) + .collect::>(); + + Self { + handoff_version: CROWDIN_TRANSLATION_HANDOFF_VERSION, + provider: TranslationHandoffProvider::Crowdin, + status: TranslationHandoffStatus::QueuedOffline, + official_release_id: change_set.official_release_id.clone(), + previous_release_id: change_set.previous_release_id.clone(), + generated_unix_seconds: unix_seconds_now(), + resource_count: resources.len(), + resources, + } + } +} + +/// Generates a change set for two complete release roots and writes both the +/// change set and the Crowdin handoff under the current release root. +pub fn write_official_resource_change_handoff( + previous_resource_root: Option<&Path>, + current_resource_root: &Path, + official_release_id: &str, + previous_release_id: Option, +) -> Result { + let current_manifest = read_download_manifest_at(current_resource_root)?.ok_or_else(|| { + format!( + "缺少当前官方下载 manifest,无法生成资源变更集:{}", + current_resource_root.display() + ) + })?; + let previous_manifest = match previous_resource_root { + Some(root) => read_download_manifest_at(root)?, + None => None, + }; + let previous_manifest_root = previous_manifest + .as_ref() + .and(previous_resource_root) + .map(Path::to_path_buf); + let previous_release_id = previous_manifest.as_ref().and(previous_release_id); + let change_set = OfficialResourceChangeSet::from_manifests( + official_release_id, + previous_release_id, + previous_manifest_root, + current_resource_root.to_path_buf(), + previous_manifest.as_ref(), + ¤t_manifest, + ); + write_resource_change_set_at(current_resource_root, &change_set)?; + + let handoff = CrowdinTranslationHandoff::from_change_set(&change_set); + write_crowdin_translation_handoff_at(current_resource_root, &handoff)?; + + Ok(OfficialResourceChangeHandoffReport { + change_set_path: current_resource_root.join(OFFICIAL_RESOURCE_CHANGES_FILE), + crowdin_handoff_path: current_resource_root.join(CROWDIN_TRANSLATION_HANDOFF_FILE), + summary: change_set.summary, + }) +} + +/// Paths and summary produced after writing a resource-change handoff. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialResourceChangeHandoffReport { + /// Path to `official-resource-changes.json`. + pub change_set_path: PathBuf, + /// Path to `crowdin-translation-handoff.json`. + pub crowdin_handoff_path: PathBuf, + /// Aggregate change counters. + pub summary: OfficialResourceChangeSummary, +} + +/// Reads a generated resource change set from a release root. +pub fn read_resource_change_set_at( + resource_root: &Path, +) -> Result, String> { + let path = resource_root.join(OFFICIAL_RESOURCE_CHANGES_FILE); + let Some(bytes) = read_file_no_symlink(&path, "官方资源变更集")? else { + return Ok(None); + }; + let change_set: OfficialResourceChangeSet = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析官方资源变更集失败 {}:{error}", path.display()))?; + if change_set.change_set_version != OFFICIAL_RESOURCE_CHANGES_VERSION { + return Err(format!( + "不支持的官方资源变更集版本 {},文件 {}", + change_set.change_set_version, + path.display() + )); + } + Ok(Some(change_set)) +} + +/// Writes a generated resource change set under a release root. +pub fn write_resource_change_set_at( + resource_root: &Path, + change_set: &OfficialResourceChangeSet, +) -> Result<(), String> { + let path = resource_root.join(OFFICIAL_RESOURCE_CHANGES_FILE); + ensure_path_within_root(resource_root, &path)?; + ensure_safe_file_target(resource_root, &path, "官方资源变更集")?; + let bytes = serde_json::to_vec_pretty(change_set) + .map_err(|error| format!("序列化官方资源变更集失败:{error}"))?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方资源变更集") +} + +/// Writes a generated Crowdin handoff under a release root. +pub fn write_crowdin_translation_handoff_at( + resource_root: &Path, + handoff: &CrowdinTranslationHandoff, +) -> Result<(), String> { + let path = resource_root.join(CROWDIN_TRANSLATION_HANDOFF_FILE); + ensure_path_within_root(resource_root, &path)?; + ensure_safe_file_target(resource_root, &path, "Crowdin 翻译 handoff")?; + let bytes = serde_json::to_vec_pretty(handoff) + .map_err(|error| format!("序列化 Crowdin 翻译 handoff 失败:{error}"))?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "Crowdin 翻译 handoff") +} + +fn entries_by_destination( + manifest: &OfficialDownloadManifest, +) -> BTreeMap { + manifest + .entries + .values() + .map(|entry| (entry.destination.clone(), entry)) + .collect() +} + +fn content_changed( + previous: &OfficialDownloadManifestEntry, + current: &OfficialDownloadManifestEntry, +) -> bool { + previous.bytes != current.bytes || previous.blake3 != current.blake3 +} + +fn unix_seconds_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn default_resource_changes_version() -> u32 { + OFFICIAL_RESOURCE_CHANGES_VERSION +} + +fn default_crowdin_handoff_version() -> u32 { + CROWDIN_TRANSLATION_HANDOFF_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest(entries: &[(&str, &str, &[u8])]) -> OfficialDownloadManifest { + let mut manifest = OfficialDownloadManifest::default(); + for (url, destination, bytes) in entries { + manifest.entries.insert( + (*url).to_string(), + OfficialDownloadManifestEntry { + url: (*url).to_string(), + destination: (*destination).to_string(), + bytes: bytes.len() as u64, + blake3: blake3::hash(bytes).to_hex().to_string(), + }, + ); + } + manifest + } + + fn write_manifest(root: &Path, manifest: &OfficialDownloadManifest) { + std::fs::create_dir_all(root).unwrap(); + std::fs::write( + root.join("official-download-manifest.json"), + serde_json::to_vec(manifest).unwrap(), + ) + .unwrap(); + } + + #[test] + fn change_set_classifies_added_modified_and_removed_resources() { + let previous = manifest(&[ + ("https://old/a", "TableBundles/a.bytes", b"old-a"), + ("https://old/b", "TableBundles/b.bytes", b"same"), + ("https://old/c", "TableBundles/c.bytes", b"removed"), + ( + "https://old/u", + "TableBundles/url-only.bytes", + b"same-url-only", + ), + ]); + let current = manifest(&[ + ("https://new/a", "TableBundles/a.bytes", b"new-a"), + ("https://new/b", "TableBundles/b.bytes", b"same"), + ("https://new/d", "TableBundles/d.bytes", b"added"), + ( + "https://changed-host/u", + "TableBundles/url-only.bytes", + b"same-url-only", + ), + ]); + + let change_set = OfficialResourceChangeSet::from_manifests( + "release-new", + Some("release-old".to_string()), + Some(PathBuf::from("/previous")), + PathBuf::from("/current"), + Some(&previous), + ¤t, + ); + + assert_eq!(change_set.summary.added_count, 1); + assert_eq!(change_set.summary.modified_count, 1); + assert_eq!(change_set.summary.removed_count, 1); + assert_eq!(change_set.summary.parse_candidate_count, 2); + assert_eq!(change_set.summary.translation_candidate_count, 2); + let destinations = change_set + .translation_candidates() + .into_iter() + .map(|change| change.destination.as_str()) + .collect::>(); + assert_eq!( + destinations, + vec!["TableBundles/a.bytes", "TableBundles/d.bytes"] + ); + } + + #[test] + fn first_release_treats_all_current_resources_as_added() { + let current = manifest(&[ + ("https://new/a", "a.bundle", b"a"), + ("https://new/b", "b.bundle", b"b"), + ]); + + let change_set = OfficialResourceChangeSet::from_manifests( + "release-new", + None, + None, + PathBuf::from("/current"), + None, + ¤t, + ); + + assert!(!change_set.summary.previous_manifest_present); + assert_eq!(change_set.summary.added_count, 2); + assert_eq!(change_set.summary.translation_candidate_count, 2); + assert_eq!(change_set.changes.len(), 2); + } + + #[test] + fn crowdin_handoff_excludes_removed_resources() { + let previous = manifest(&[("https://old/a", "a.bundle", b"a")]); + let current = manifest(&[("https://new/b", "b.bundle", b"b")]); + let change_set = OfficialResourceChangeSet::from_manifests( + "release-new", + Some("release-old".to_string()), + None, + PathBuf::from("/current"), + Some(&previous), + ¤t, + ); + + let handoff = CrowdinTranslationHandoff::from_change_set(&change_set); + + assert_eq!(handoff.provider, TranslationHandoffProvider::Crowdin); + assert_eq!(handoff.status, TranslationHandoffStatus::QueuedOffline); + assert_eq!(handoff.resource_count, 1); + assert_eq!(handoff.resources[0].destination, "b.bundle"); + } + + #[test] + fn write_handoff_persists_change_set_and_crowdin_queue() { + let temp = tempfile::TempDir::new().unwrap(); + let previous_root = temp.path().join("previous"); + let current_root = temp.path().join("current"); + write_manifest( + &previous_root, + &manifest(&[("https://old/a", "a.bundle", b"old")]), + ); + write_manifest( + ¤t_root, + &manifest(&[ + ("https://new/a", "a.bundle", b"new"), + ("https://new/b", "b.bundle", b"added"), + ]), + ); + + let report = write_official_resource_change_handoff( + Some(&previous_root), + ¤t_root, + "release-new", + Some("release-old".to_string()), + ) + .unwrap(); + + assert_eq!(report.summary.modified_count, 1); + assert_eq!(report.summary.added_count, 1); + assert!(report.change_set_path.exists()); + assert!(report.crowdin_handoff_path.exists()); + let change_set = read_resource_change_set_at(¤t_root).unwrap().unwrap(); + assert_eq!(change_set.summary.translation_candidate_count, 2); + } +} diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 483c913..abd7094 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -115,8 +115,8 @@ pub struct OfficialResourcePullProgress { /// /// `Started` events report the currently completed count before the URL /// finishes; `Finished` events report the count after completion. This is - /// intentionally not the URL's plan position, because concurrent downloads - /// finish out of plan order and status percentages must not move backward. + /// intentionally not the URL's plan position, so status percentages stay + /// monotonic even if execution order or skip/resume mix changes. pub index: usize, /// Total URL count in the pull plan. pub total: usize, @@ -2272,14 +2272,16 @@ exit 22 } fn write_shell_script(path: &Path, script: &str) { - fs::write(path, script).unwrap(); + let temp_path = path.with_extension("tmp"); + fs::write(&temp_path, script).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(path).unwrap().permissions(); + let mut permissions = fs::metadata(&temp_path).unwrap().permissions(); permissions.set_mode(0o755); - fs::set_permissions(path, permissions).unwrap(); + fs::set_permissions(&temp_path, permissions).unwrap(); } + fs::rename(temp_path, path).unwrap(); } fn write_fake_curl(path: &Path) { diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index 074959c..02603d2 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -9,7 +9,8 @@ use crate::official_download::DownloadError; use crate::official_launcher::launcher_package_url; use crate::official_launcher::OfficialLauncherBootstrapService; use crate::official_launcher::{ - YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherRemoteManifest, + YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, + YostarJpLauncherRemoteManifest, }; use crate::zip_validation::validate_zip_structure; use bat_adapters::official::game_main_config::YostarJpGameMainConfig; @@ -39,12 +40,44 @@ pub struct OfficialGameMainConfigBootstrap { /// may instead point to a directory source plus per-file entries; in that /// case this is the direct `resources.assets` URL. pub game_zip_url: String, + /// Remote launcher manifest used for this bootstrap. + pub remote_manifest: YostarJpLauncherRemoteManifest, + /// Exact source selected to obtain `GameMainConfig`. + pub selected_source: OfficialGameMainConfigSelectedSource, /// Number of files declared by the remote manifest. pub manifest_file_count: usize, /// Decrypted `GameMainConfig`. pub game_main_config: YostarJpGameMainConfig, } +/// Kind of official launcher package artifact selected for `GameMainConfig`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OfficialGameMainConfigSourceKind { + /// Older launcher manifests point to a single game ZIP archive. + Archive, + /// Current launcher manifests expose a directory plus per-file entries. + ManifestFile, +} + +/// Exact launcher artifact selected to obtain `resources.assets`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfficialGameMainConfigSelectedSource { + /// Source kind. + pub kind: OfficialGameMainConfigSourceKind, + /// Official URL fetched for this source. + pub url: String, + /// Relative path under the official launcher package CDN root. + pub relative_path: String, + /// Original manifest file path when the source is a manifest file entry. + pub manifest_path: Option, + /// Declared file size from the manifest, when available. + pub declared_size: Option, + /// Official launcher manifest `hash` field, when available. + pub official_hash: Option, + /// Official launcher manifest per-file `vc`, when available. + pub vc: Option, +} + /// Loads and decrypts the official `GameMainConfig` by following the official /// launcher package chain. #[derive(Debug, Clone)] @@ -92,6 +125,18 @@ impl OfficialGameMainConfigBootstrapService { /// launcher API, extracts `resources.assets`, and decrypts `GameMainConfig`. pub fn fetch_bootstrap(&self) -> Result { let (game_config, manifest_url, manifest) = self.launcher.fetch_latest_remote_manifest()?; + let cdn_config = self.launcher.fetch_cdn_config()?; + self.fetch_bootstrap_from_parts(game_config, cdn_config, manifest_url, manifest) + } + + /// Extracts `GameMainConfig` from already fetched launcher metadata. + pub fn fetch_bootstrap_from_parts( + &self, + game_config: YostarJpLauncherGameConfig, + cdn_config: YostarJpLauncherCdnConfig, + manifest_url: YostarJpLauncherManifestUrl, + manifest: YostarJpLauncherRemoteManifest, + ) -> Result { let manifest_source = manifest .source .clone() @@ -102,9 +147,8 @@ impl OfficialGameMainConfigBootstrapService { "官方启动器远端 manifest 缺少 source", ) })?; - let cdn_config = self.launcher.fetch_cdn_config()?; let temp_dir = TempDir::new().map_err(|error| format!("创建临时目录失败:{error}"))?; - let (game_zip_url, resources_assets) = self.fetch_resources_assets( + let (game_zip_url, resources_assets, selected_source) = self.fetch_resources_assets( &game_config, &manifest, &manifest_source, @@ -113,6 +157,7 @@ impl OfficialGameMainConfigBootstrapService { )?; let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets) .map_err(|error| DownloadError::new(ErrorCode::GAME_MAIN_CONFIG_FAILED, error))?; + let manifest_file_count = manifest.files.len(); Ok(OfficialGameMainConfigBootstrap { game_config, @@ -120,7 +165,9 @@ impl OfficialGameMainConfigBootstrapService { manifest_url: manifest_url.url, manifest_source: Some(manifest_source), game_zip_url, - manifest_file_count: manifest.files.len(), + remote_manifest: manifest, + selected_source, + manifest_file_count, game_main_config, }) } @@ -203,10 +250,12 @@ impl OfficialGameMainConfigBootstrapService { manifest_source: &str, cdn_config: &YostarJpLauncherCdnConfig, temp_root: &Path, - ) -> Result<(String, PathBuf), DownloadError> { + ) -> Result<(String, PathBuf, OfficialGameMainConfigSelectedSource), DownloadError> { + let selected_source = + resolve_game_main_config_source(game_config, manifest, manifest_source, cdn_config)?; match select_game_main_config_source(game_config, manifest_source, manifest)? { GameMainConfigSource::Archive(package_path) => { - let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?; + let game_zip_url = selected_source.url.clone(); let archive_path = temp_root.join("official-game.zip"); self.download_file_with_fallback( &game_zip_url, @@ -227,12 +276,11 @@ impl OfficialGameMainConfigBootstrapService { "官方启动器包内没有找到 resources.assets", ) })?; - Ok((game_zip_url, resources_assets)) + Ok((game_zip_url, resources_assets, selected_source)) } GameMainConfigSource::ManifestFile { source_dir, file } => { let relative_path = launcher_manifest_file_relative_path(source_dir, &file.path)?; - let resources_assets_url = - launcher_package_url(&cdn_config.primary_cdn, &relative_path)?; + let resources_assets_url = selected_source.url.clone(); let resources_assets = temp_root.join("resources.assets"); self.download_file_with_fallback( &resources_assets_url, @@ -241,7 +289,7 @@ impl OfficialGameMainConfigBootstrapService { &resources_assets, )?; verify_manifest_file_size(&resources_assets, file)?; - Ok((resources_assets_url, resources_assets)) + Ok((resources_assets_url, resources_assets, selected_source)) } } } @@ -283,6 +331,49 @@ impl OfficialGameMainConfigBootstrapService { } } +/// Resolves the exact official launcher artifact used to obtain `GameMainConfig`. +pub fn resolve_game_main_config_source( + game_config: &YostarJpLauncherGameConfig, + manifest: &YostarJpLauncherRemoteManifest, + manifest_source: &str, + cdn_config: &YostarJpLauncherCdnConfig, +) -> Result { + match select_game_main_config_source(game_config, manifest_source, manifest)? { + GameMainConfigSource::Archive(package_path) => { + let url = launcher_package_url(&cdn_config.primary_cdn, package_path)?; + Ok(OfficialGameMainConfigSelectedSource { + kind: OfficialGameMainConfigSourceKind::Archive, + url, + relative_path: package_path.to_string(), + manifest_path: None, + declared_size: None, + official_hash: None, + vc: None, + }) + } + GameMainConfigSource::ManifestFile { source_dir, file } => { + let relative_path = launcher_manifest_file_relative_path(source_dir, &file.path) + .map_err(|error| DownloadError::new(ErrorCode::LAUNCHER_RESPONSE_INVALID, error))?; + let url = launcher_package_url(&cdn_config.primary_cdn, &relative_path)?; + let declared_size = file.size.parse::().map_err(|error| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path), + ) + })?; + Ok(OfficialGameMainConfigSelectedSource { + kind: OfficialGameMainConfigSourceKind::ManifestFile, + url, + relative_path, + manifest_path: Some(file.path.clone()), + declared_size: Some(declared_size), + official_hash: Some(file.hash.clone()), + vc: file.vc.clone(), + }) + } + } +} + #[derive(Debug)] enum GameMainConfigSource<'a> { Archive(&'a str), diff --git a/infrastructure/src/official_parse.rs b/infrastructure/src/official_parse.rs index b67bc5e..8248292 100644 --- a/infrastructure/src/official_parse.rs +++ b/infrastructure/src/official_parse.rs @@ -10,9 +10,11 @@ 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 bat_assetbundle::{ + Parser, TextUnit, TextUnitExtractionError, TextUnitExtractor, UnityFsParser, +}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; @@ -20,7 +22,11 @@ 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; +pub const OFFICIAL_PARSE_CACHE_VERSION: u32 = 2; +/// TextUnit detail index file name stored under a published official resource root. +pub const OFFICIAL_TEXTUNIT_INDEX_FILE: &str = "official-textunit-index.json"; +/// Current TextUnit detail-index schema version. +pub const OFFICIAL_TEXTUNIT_INDEX_VERSION: u32 = 1; /// Configuration for one official resource parse-cache refresh. #[derive(Debug, Clone, PartialEq, Eq)] @@ -55,6 +61,10 @@ pub struct OfficialParseReport { pub cache_path: PathBuf, /// Aggregate parse-cache summary. pub summary: OfficialParseSummary, + /// TextUnit detail-index path written by the refresh. + pub textunit_index_path: PathBuf, + /// Aggregate TextUnit detail-index summary. + pub textunit_index_summary: OfficialTextUnitIndexSummary, } /// Aggregate counters for a parse-cache refresh. @@ -78,6 +88,15 @@ pub struct OfficialParseSummary { pub failed_count: usize, /// Total TextAsset objects found in parsed Unity serialized files. pub text_asset_count: usize, + /// Total TextUnit objects extracted from TextAsset payloads and TypeTree string fields. + #[serde(default)] + pub text_unit_count: usize, + /// Number of binary/invalid TextAsset payloads skipped by the TextUnit extractor. + #[serde(default)] + pub skipped_binary_text_asset_count: usize, + /// Number of non-fatal TypeTree field extraction diagnostics. + #[serde(default)] + pub text_unit_error_count: usize, } /// Persistent parse cache for one official resource root. @@ -127,10 +146,162 @@ pub struct OfficialParseCacheEntry { pub text_assets: Vec, /// Non-fatal serialized-file parse diagnostic count. pub serialized_parse_error_count: usize, + /// Number of translation-ready TextUnit entries extracted from this bundle. + #[serde(default)] + pub text_unit_count: usize, + /// Stable set of TextUnit payload formats such as json/csv/tsv/plain. + #[serde(default)] + pub text_unit_formats: Vec, + /// Number of binary/invalid TextAsset payloads skipped by TextUnit extraction. + #[serde(default)] + pub skipped_binary_text_asset_count: usize, + /// Non-fatal TypeTree field extraction diagnostic count. + #[serde(default)] + pub text_unit_error_count: usize, /// Human-readable error or skip reason. pub error: Option, } +/// Persistent TextUnit detail index for one official resource root. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitIndex { + /// Index schema version. + #[serde(default = "default_textunit_index_version")] + pub version: u32, + /// Index generation time as Unix seconds. + pub generated_unix_seconds: u64, + /// Official resource root used to generate this index. + pub resource_root: PathBuf, + /// Aggregate counters for this index. + pub summary: OfficialTextUnitIndexSummary, + /// Extracted TextUnit details in deterministic order. + #[serde(default)] + pub units: Vec, + /// Parse and extraction diagnostics in deterministic order. + #[serde(default)] + pub errors: Vec, +} + +/// Aggregate counters for a TextUnit detail index. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitIndexSummary { + /// Number of TextUnit detail entries. + pub unit_count: usize, + /// Number of parse/extraction diagnostics. + pub error_count: usize, + /// Number of binary or invalid TextAsset payloads skipped by extraction. + pub skipped_binary_text_asset_count: usize, +} + +/// One TextUnit detail entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitIndexUnit { + /// Stable unit ID within the official release. + pub id: String, + /// Parse-cache entry key that produced this unit. + pub parse_entry_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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_entry: Option, + /// Source classification used by the parser. + pub source_kind: OfficialParseSourceKind, + /// Unity editor version when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unity_version: Option, + /// Original source text. + pub source_text: String, + /// Unity serialized file path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub serialized_file: Option, + /// Unity object path ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_id: Option, + /// Unity class ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class_id: Option, + /// TypeTree field path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_path: Option, + /// Byte offset relative to the beginning of the Unity object payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_offset: Option, + /// Number of bytes consumed by this field, including alignment padding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_byte_size: Option, + /// TextUnit payload format such as json/csv/tsv/plain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + /// Extraction source kind such as TextAsset or TypeTreeField. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_source_kind: Option, + /// TextAsset name when the unit came from a TextAsset payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_name: Option, + /// Stable extraction context copied from the parser. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub context: BTreeMap, +} + +/// One parse or extraction diagnostic in the TextUnit detail index. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitIndexError { + /// Stable diagnostic ID within the official release. + pub id: String, + /// Parse-cache entry key associated with this diagnostic. + pub parse_entry_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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_entry: Option, + /// Source classification used by the parser. + pub source_kind: OfficialParseSourceKind, + /// Parse status associated with this diagnostic. + pub status: OfficialParseStatus, + /// Unity serialized file path when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub serialized_file: Option, + /// Unity object path ID when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_id: Option, + /// Unity class ID when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class_id: Option, + /// TypeTree field path when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_path: Option, + /// Byte offset relative to the beginning of the Unity object payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Human-readable diagnostic. + pub error: String, +} + +/// Query filters for TextUnit detail entries. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OfficialTextUnitQuery { + /// Filter by destination path. + pub destination: Option, + /// Filter by destination glob pattern. + pub path_pattern: Option, + /// Filter by archive entry. + pub archive_entry: Option, + /// Filter by Unity object path ID. + pub path_id: Option, + /// Filter by Unity class ID. + pub class_id: Option, + /// Filter by field path. + pub field_path: Option, + /// Filter by TextUnit format. + pub format: Option, +} + /// Source kind for a parse-cache entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -187,21 +358,36 @@ impl OfficialParseCacheService { ) })?; let previous_cache = read_parse_cache_at(&config.resource_root)?; + let previous_textunit_index = read_textunit_index_at(&config.resource_root)?; let mut summary = OfficialParseSummary { manifest_entry_count: manifest.entries.len(), ..OfficialParseSummary::default() }; let mut entries = BTreeMap::new(); + let mut units = Vec::new(); + let mut errors = Vec::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); + let produced = process_manifest_entry( + config, + manifest_entry, + previous_cache.as_ref(), + previous_textunit_index.as_ref(), + ); + for produced in produced { + summary.record_entry(&produced.entry); + units.extend(produced.units); + errors.extend(produced.errors); + entries.insert(produced.entry.key.clone(), produced.entry); } } summary.cache_entry_count = entries.len(); + let textunit_index_summary = OfficialTextUnitIndexSummary { + unit_count: units.len(), + error_count: errors.len(), + skipped_binary_text_asset_count: summary.skipped_binary_text_asset_count, + }; let cache = OfficialParseCache { version: OFFICIAL_PARSE_CACHE_VERSION, generated_unix_seconds: unix_seconds_now(), @@ -209,15 +395,67 @@ impl OfficialParseCacheService { entries, }; write_parse_cache_at(&config.resource_root, &cache)?; + let textunit_index = OfficialTextUnitIndex { + version: OFFICIAL_TEXTUNIT_INDEX_VERSION, + generated_unix_seconds: cache.generated_unix_seconds, + resource_root: config.resource_root.clone(), + summary: textunit_index_summary.clone(), + units, + errors, + }; + write_textunit_index_at(&config.resource_root, &textunit_index)?; Ok(OfficialParseReport { resource_root: config.resource_root.clone(), cache_path: config.cache_path(), summary, + textunit_index_path: config.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE), + textunit_index_summary, }) } } +struct ParseProduced { + entry: OfficialParseCacheEntry, + units: Vec, + errors: Vec, +} + +impl ParseProduced { + fn from_entry(entry: OfficialParseCacheEntry) -> Self { + let errors = error_from_status_entry(&entry).into_iter().collect(); + Self { + entry, + units: Vec::new(), + errors, + } + } + + fn from_cached( + mut entry: OfficialParseCacheEntry, + previous_index: Option<&OfficialTextUnitIndex>, + ) -> Self { + entry.reused_from_previous_cache = true; + let units = previous_index + .into_iter() + .flat_map(|index| index.units.iter()) + .filter(|unit| unit.parse_entry_key == entry.key) + .cloned() + .collect(); + let errors = previous_index + .into_iter() + .flat_map(|index| index.errors.iter()) + .filter(|error| error.parse_entry_key == entry.key) + .cloned() + .collect(); + Self { + entry, + units, + errors, + } + } +} + impl OfficialParseSummary { fn record_entry(&mut self, entry: &OfficialParseCacheEntry) { match entry.source_kind { @@ -234,6 +472,9 @@ impl OfficialParseSummary { OfficialParseStatus::Parsed => { self.parsed_bundle_count += 1; self.text_asset_count += entry.text_asset_count; + self.text_unit_count += entry.text_unit_count; + self.skipped_binary_text_asset_count += entry.skipped_binary_text_asset_count; + self.text_unit_error_count += entry.text_unit_error_count; } OfficialParseStatus::SkippedUnsupported => { self.unsupported_count += 1; @@ -279,21 +520,81 @@ pub fn write_parse_cache_at( write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方解析缓存") } +/// Reads the TextUnit detail index under a published official resource root. +pub fn read_textunit_index_at( + resource_root: &Path, +) -> Result, String> { + let path = resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE); + let Some(bytes) = read_file_no_symlink(&path, "官方 TextUnit 明细索引")? else { + return Ok(None); + }; + let Ok(index) = serde_json::from_slice::(&bytes) else { + return Ok(None); + }; + if index.version != OFFICIAL_TEXTUNIT_INDEX_VERSION { + return Ok(None); + } + Ok(Some(index)) +} + +/// Writes the TextUnit detail index under a published official resource root. +pub fn write_textunit_index_at( + resource_root: &Path, + index: &OfficialTextUnitIndex, +) -> Result<(), String> { + let path = resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE); + ensure_path_within_root(resource_root, &path)?; + ensure_safe_file_target(resource_root, &path, "官方 TextUnit 明细索引")?; + let bytes = serde_json::to_vec_pretty(index) + .map_err(|error| format!("序列化官方 TextUnit 明细索引失败:{error}"))?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方 TextUnit 明细索引") +} + +/// Returns TextUnit detail entries matching a query. +pub fn query_textunit_index_units<'a>( + index: &'a OfficialTextUnitIndex, + query: &OfficialTextUnitQuery, +) -> Vec<&'a OfficialTextUnitIndexUnit> { + index + .units + .iter() + .filter(|unit| textunit_unit_matches(unit, query)) + .collect() +} + +/// Returns parse/extraction diagnostics matching a query. +pub fn query_textunit_index_errors<'a>( + index: &'a OfficialTextUnitIndex, + query: &OfficialTextUnitQuery, +) -> Vec<&'a OfficialTextUnitIndexError> { + index + .errors + .iter() + .filter(|error| textunit_error_matches(error, query)) + .collect() +} + fn process_manifest_entry( config: &OfficialParseConfig, manifest_entry: &OfficialDownloadManifestEntry, previous_cache: Option<&OfficialParseCache>, -) -> Vec { + previous_index: Option<&OfficialTextUnitIndex>, +) -> Vec { let fingerprint = fingerprint_for(manifest_entry); if looks_like_zip_source(manifest_entry) { - return process_zip_entry(config, manifest_entry, previous_cache, fingerprint); + return process_zip_entry( + config, + manifest_entry, + previous_cache, + previous_index, + 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]; + if let Some(cached) = reusable_entry(previous_cache, &key, &fingerprint) { + return vec![ParseProduced::from_cached(cached, previous_index)]; } return vec![parse_direct_bundle( config, @@ -304,61 +605,58 @@ fn process_manifest_entry( } 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]; + if let Some(cached) = reusable_entry(previous_cache, &key, &fingerprint) { + return vec![ParseProduced::from_cached(cached, previous_index)]; } - vec![unsupported_entry( + vec![ParseProduced::from_entry(unsupported_entry( manifest_entry, None, OfficialParseSourceKind::Unsupported, fingerprint, key, "非 UnityFS 候选资源", - )] + ))] } fn process_zip_entry( config: &OfficialParseConfig, manifest_entry: &OfficialDownloadManifestEntry, previous_cache: Option<&OfficialParseCache>, + previous_index: Option<&OfficialTextUnitIndex>, fingerprint: OfficialParseSourceFingerprint, -) -> Vec { +) -> Vec { 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 - }) + .map(|entry| ParseProduced::from_cached(entry, previous_index)) .collect(); } let archive_path = match resource_path_for(&config.resource_root, manifest_entry) { Ok(path) => path, Err(error) => { - return vec![failed_entry( + return vec![ParseProduced::from_entry(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( + return vec![ParseProduced::from_entry(failed_entry( manifest_entry, None, OfficialParseSourceKind::ZipEntry, fingerprint, zip_list_key(&manifest_entry.url), error, - )] + ))] } }; @@ -369,14 +667,14 @@ fn process_zip_entry( { Ok(bytes) => bytes, Err(error) => { - produced.push(failed_entry( + produced.push(ParseProduced::from_entry(failed_entry( manifest_entry, Some(archive_entry), OfficialParseSourceKind::ZipEntry, fingerprint.clone(), key, error, - )); + ))); continue; } }; @@ -390,14 +688,14 @@ fn process_zip_entry( } if produced.is_empty() { - produced.push(unsupported_entry( + produced.push(ParseProduced::from_entry(unsupported_entry( manifest_entry, None, OfficialParseSourceKind::Unsupported, fingerprint, zip_list_key(&manifest_entry.url), "ZIP 内没有可检查文件条目", - )); + ))); } produced } @@ -407,42 +705,42 @@ fn parse_direct_bundle( manifest_entry: &OfficialDownloadManifestEntry, key: String, fingerprint: OfficialParseSourceFingerprint, -) -> OfficialParseCacheEntry { +) -> ParseProduced { let path = match resource_path_for(&config.resource_root, manifest_entry) { Ok(path) => path, Err(error) => { - return failed_entry( + return ParseProduced::from_entry(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( + return ParseProduced::from_entry(failed_entry( manifest_entry, None, OfficialParseSourceKind::DirectBundle, fingerprint, key, error, - ) + )) } }; if !UnityFsParser::has_unityfs_signature(&bytes) { - return unsupported_entry( + return ParseProduced::from_entry(unsupported_entry( manifest_entry, None, OfficialParseSourceKind::DirectBundle, fingerprint, key, "文件不是 UnityFS bundle", - ); + )); } parsed_bundle_entry( manifest_entry, @@ -460,16 +758,16 @@ fn parse_zip_inner_file( fingerprint: OfficialParseSourceFingerprint, key: String, bytes: &[u8], -) -> OfficialParseCacheEntry { +) -> ParseProduced { if !UnityFsParser::has_unityfs_signature(bytes) { - return unsupported_entry( + return ParseProduced::from_entry(unsupported_entry( manifest_entry, Some(archive_entry), OfficialParseSourceKind::ZipEntry, fingerprint, key, "ZIP 条目不是 UnityFS bundle", - ); + )); } parsed_bundle_entry( manifest_entry, @@ -488,38 +786,254 @@ fn parsed_bundle_entry( fingerprint: OfficialParseSourceFingerprint, key: String, bytes: &[u8], -) -> OfficialParseCacheEntry { +) -> ParseProduced { 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 + Ok(parsed) => { + let text_units = TextUnitExtractor::new().extract_bundle_with_context( + &parsed, + Some(&manifest_entry.destination), + archive_entry.as_deref(), + ); + let text_unit_formats = text_units + .units .iter() - .map(|asset| asset.name.clone()) - .collect(), - serialized_parse_error_count: parsed.serialized_parse_errors.len(), - error: None, - }, - Err(error) => failed_entry( + .filter_map(|unit| unit.context.get("format").cloned()) + .collect::>() + .into_iter() + .collect(); + + let entry = 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(), + text_unit_count: text_units.units.len(), + text_unit_formats, + skipped_binary_text_asset_count: text_units.skipped_binary_text_assets, + text_unit_error_count: text_units.errors.len(), + error: None, + }; + let units = indexed_text_units_for_entry(&entry, &text_units.units); + let errors = indexed_extraction_errors_for_entry(&entry, &text_units.errors); + ParseProduced { + entry, + units, + errors, + } + } + Err(error) => ParseProduced::from_entry(failed_entry( manifest_entry, archive_entry, source_kind, fingerprint, key, error.to_string(), - ), + )), + } +} + +fn indexed_text_units_for_entry( + entry: &OfficialParseCacheEntry, + units: &[TextUnit], +) -> Vec { + units + .iter() + .enumerate() + .map(|(index, unit)| OfficialTextUnitIndexUnit { + id: format!("{}#unit:{index}", entry.key), + parse_entry_key: entry.key.clone(), + source_url: entry.source_url.clone(), + destination: entry.destination.clone(), + archive_entry: entry.archive_entry.clone(), + source_kind: entry.source_kind, + unity_version: entry.unity_version.clone(), + source_text: unit.source_text.clone(), + serialized_file: unit.serialized_file.clone(), + path_id: unit.path_id, + class_id: unit.class_id, + field_path: unit.field_path.clone(), + field_offset: unit.field_offset, + field_byte_size: unit.field_byte_size, + format: unit.context.get("format").cloned(), + text_source_kind: unit.context.get("source_kind").cloned(), + asset_name: unit.context.get("asset_name").cloned(), + context: unit.context.clone(), + }) + .collect() +} + +fn indexed_extraction_errors_for_entry( + entry: &OfficialParseCacheEntry, + errors: &[TextUnitExtractionError], +) -> Vec { + errors + .iter() + .enumerate() + .map(|(index, error)| OfficialTextUnitIndexError { + id: format!("{}#extract-error:{index}", entry.key), + parse_entry_key: entry.key.clone(), + source_url: entry.source_url.clone(), + destination: entry.destination.clone(), + archive_entry: entry.archive_entry.clone(), + source_kind: entry.source_kind, + status: entry.status, + serialized_file: error.serialized_file.clone(), + path_id: error.path_id, + class_id: error.class_id, + field_path: error.field_path.clone(), + offset: error.offset, + error: error.error.clone(), + }) + .collect() +} + +fn error_from_status_entry(entry: &OfficialParseCacheEntry) -> Option { + let error = entry.error.as_ref()?; + Some(OfficialTextUnitIndexError { + id: format!("{}#status", entry.key), + parse_entry_key: entry.key.clone(), + source_url: entry.source_url.clone(), + destination: entry.destination.clone(), + archive_entry: entry.archive_entry.clone(), + source_kind: entry.source_kind, + status: entry.status, + serialized_file: None, + path_id: None, + class_id: None, + field_path: None, + offset: None, + error: error.clone(), + }) +} + +fn textunit_unit_matches(unit: &OfficialTextUnitIndexUnit, query: &OfficialTextUnitQuery) -> bool { + if query + .destination + .as_ref() + .is_some_and(|destination| &unit.destination != destination) + { + return false; + } + if query + .path_pattern + .as_ref() + .is_some_and(|pattern| !glob_matches(pattern, &unit.destination)) + { + return false; + } + if query + .archive_entry + .as_ref() + .is_some_and(|archive_entry| unit.archive_entry.as_ref() != Some(archive_entry)) + { + return false; + } + if query + .path_id + .is_some_and(|path_id| unit.path_id != Some(path_id)) + { + return false; + } + if query + .class_id + .is_some_and(|class_id| unit.class_id != Some(class_id)) + { + return false; + } + if query + .field_path + .as_ref() + .is_some_and(|field_path| unit.field_path.as_ref() != Some(field_path)) + { + return false; + } + if query + .format + .as_ref() + .is_some_and(|format| unit.format.as_ref() != Some(format)) + { + return false; + } + true +} + +fn textunit_error_matches( + error: &OfficialTextUnitIndexError, + query: &OfficialTextUnitQuery, +) -> bool { + if query + .destination + .as_ref() + .is_some_and(|destination| &error.destination != destination) + { + return false; + } + if query + .path_pattern + .as_ref() + .is_some_and(|pattern| !glob_matches(pattern, &error.destination)) + { + return false; + } + if query + .archive_entry + .as_ref() + .is_some_and(|archive_entry| error.archive_entry.as_ref() != Some(archive_entry)) + { + return false; + } + if query + .path_id + .is_some_and(|path_id| error.path_id != Some(path_id)) + { + return false; + } + if query + .class_id + .is_some_and(|class_id| error.class_id != Some(class_id)) + { + return false; + } + if query + .field_path + .as_ref() + .is_some_and(|field_path| error.field_path.as_ref() != Some(field_path)) + { + return false; + } + true +} + +fn glob_matches(pattern: &str, value: &str) -> bool { + glob_matches_bytes(pattern.as_bytes(), value.as_bytes()) +} + +fn glob_matches_bytes(pattern: &[u8], value: &[u8]) -> bool { + match pattern.split_first() { + None => value.is_empty(), + Some((&b'*', rest)) => { + glob_matches_bytes(rest, value) + || (!value.is_empty() && glob_matches_bytes(pattern, &value[1..])) + } + Some((&b'?', rest)) => !value.is_empty() && glob_matches_bytes(rest, &value[1..]), + Some((&literal, rest)) => value + .split_first() + .is_some_and(|(&head, tail)| head == literal && glob_matches_bytes(rest, tail)), } } @@ -585,6 +1099,10 @@ fn status_entry( text_asset_count: 0, text_assets: Vec::new(), serialized_parse_error_count: 0, + text_unit_count: 0, + text_unit_formats: Vec::new(), + skipped_binary_text_asset_count: 0, + text_unit_error_count: 0, error: Some(reason), } } @@ -735,6 +1253,10 @@ fn default_parse_cache_version() -> u32 { OFFICIAL_PARSE_CACHE_VERSION } +fn default_textunit_index_version() -> u32 { + OFFICIAL_TEXTUNIT_INDEX_VERSION +} + fn unix_seconds_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/infrastructure/src/official_repository.rs b/infrastructure/src/official_repository.rs new file mode 100644 index 0000000..8f62875 --- /dev/null +++ b/infrastructure/src/official_repository.rs @@ -0,0 +1,543 @@ +//! Import of a verified official release into CAS and ResourceRepository. + +use crate::official_download::{read_download_manifest_at, OfficialDownloadManifestEntry}; +use crate::official_parse::{ + read_parse_cache_at, OfficialParseCache, OfficialParseCacheEntry, OfficialParseStatus, + OfficialParseSummary, +}; +use crate::path_security::{ + ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, +}; +use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType}; +use bat_core::repositories::{CasRepository, ResourceRepository}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +/// Configuration for importing one already-published official release. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfficialReleaseImportConfig { + /// Published release root containing `official-download-manifest.json`. + pub release_root: PathBuf, + /// Official release ID associated with this root, when known. + pub official_release_id: Option, +} + +impl OfficialReleaseImportConfig { + /// Creates an import configuration. + pub fn new(release_root: impl Into) -> Self { + Self { + release_root: release_root.into(), + official_release_id: None, + } + } + + /// Attaches the official release ID that should be stored in resource metadata. + pub fn with_official_release_id(mut self, release_id: impl Into) -> Self { + self.official_release_id = Some(release_id.into()); + self + } +} + +/// Summary returned by an official release repository import. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialReleaseImportReport { + /// Number of verified manifest entries. + pub manifest_entry_count: usize, + /// Number of new or changed repository rows. + pub imported_count: usize, + /// Number of rows already pointing at the same CAS object. + pub unchanged_count: usize, + /// Number of unchanged rows whose metadata was refreshed from parse cache. + #[serde(default)] + pub metadata_updated_count: usize, + /// Number of resources classified as AssetBundle. + pub asset_bundle_count: usize, + /// Number of resources classified as text-like payloads. + pub text_asset_count: usize, + /// Number of resources classified as tables. + pub table_count: usize, + /// Number of resources classified as media. + pub media_count: usize, + /// Parse-cache summary associated with this release, when available. + pub parse_summary: Option, + /// Non-fatal cleanup warnings, such as an old CAS reference that could not + /// be decremented after a successful row replacement. + pub warnings: Vec, +} + +/// Imports verified official resources into CAS and the resource index. +pub struct OfficialReleaseImportService<'a> { + cas: &'a dyn CasRepository, + resources: &'a dyn ResourceRepository, +} + +impl<'a> OfficialReleaseImportService<'a> { + /// Creates an import service. + pub fn new(cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository) -> Self { + Self { cas, resources } + } + + /// Imports every entry in one published release manifest. + /// + /// The source files remain untouched. Every file is checked against the + /// verified download manifest before it can enter CAS. Repository IDs are + /// stable by destination, making repeated imports idempotent. + pub async fn import_release( + &self, + config: &OfficialReleaseImportConfig, + ) -> bat_core::Result { + let manifest = read_download_manifest_at(&config.release_root) + .map_err(bat_core::Error::InvalidArgument)? + .ok_or_else(|| { + bat_core::Error::NotFound(format!( + "官方下载 manifest 不存在:{}", + config.release_root.display() + )) + })?; + let parse_cache = + read_parse_cache_at(&config.release_root).map_err(bat_core::Error::InvalidArgument)?; + let parse_summary = parse_cache.as_ref().map(|cache| cache.summary.clone()); + let parse_entries_by_destination = parse_cache + .as_ref() + .map(parse_entries_by_destination) + .unwrap_or_default(); + let release_id = config + .official_release_id + .clone() + .or_else(|| release_id_from_root(&config.release_root)); + let mut report = OfficialReleaseImportReport { + manifest_entry_count: manifest.entries.len(), + imported_count: 0, + unchanged_count: 0, + metadata_updated_count: 0, + asset_bundle_count: 0, + text_asset_count: 0, + table_count: 0, + media_count: 0, + parse_summary, + warnings: Vec::new(), + }; + + for entry in manifest.entries.values() { + let parse_entries = parse_entries_by_destination + .get(&entry.destination) + .map(Vec::as_slice) + .unwrap_or(&[]); + self.import_entry( + &config.release_root, + release_id.as_deref(), + entry, + parse_entries, + &mut report, + ) + .await?; + } + + Ok(report) + } + + async fn import_entry( + &self, + release_root: &Path, + official_release_id: Option<&str>, + manifest_entry: &OfficialDownloadManifestEntry, + parse_entries: &[&OfficialParseCacheEntry], + report: &mut OfficialReleaseImportReport, + ) -> bat_core::Result<()> { + let path = release_root.join(Path::new(&manifest_entry.destination)); + ensure_path_within_root(release_root, &path).map_err(bat_core::Error::InvalidArgument)?; + ensure_safe_file_target(release_root, &path, "官方资源导入输入") + .map_err(bat_core::Error::InvalidArgument)?; + let bytes = read_file_no_symlink(&path, "官方资源导入输入") + .map_err(bat_core::Error::InvalidArgument)? + .ok_or_else(|| bat_core::Error::NotFound(path.display().to_string()))?; + if bytes.len() as u64 != manifest_entry.bytes { + return Err(bat_core::Error::InvalidArgument(format!( + "官方资源导入 size 校验失败 {}:期望 {},实际 {}", + manifest_entry.destination, + manifest_entry.bytes, + bytes.len() + ))); + } + let actual_hash = blake3::hash(&bytes).to_hex().to_string(); + if actual_hash != manifest_entry.blake3 { + return Err(bat_core::Error::InvalidArgument(format!( + "官方资源导入 BLAKE3 校验失败 {}:期望 {},实际 {}", + manifest_entry.destination, manifest_entry.blake3, actual_hash + ))); + } + + let resource_type = resource_type_for_path(&manifest_entry.destination); + let metadata = + metadata_for_manifest_entry(official_release_id, manifest_entry, parse_entries); + let resource_id = resource_id_for_destination(&manifest_entry.destination); + let previous = match self.resources.find_by_id(&resource_id).await { + Ok(resource) => Some(resource), + Err(bat_core::Error::NotFound(_)) => None, + Err(error) => return Err(error), + }; + if previous + .as_ref() + .is_some_and(|resource| resource.entry.hash == actual_hash) + { + if let Some(mut resource) = previous { + let should_refresh_metadata = resource.metadata != metadata + || resource.entry.resource_type != resource_type + || resource.entry.size != manifest_entry.bytes + || resource.local_path.as_path() != Path::new(&manifest_entry.destination); + if should_refresh_metadata { + resource.local_path = PathBuf::from(&manifest_entry.destination); + resource.entry.size = manifest_entry.bytes; + resource.entry.resource_type = resource_type; + resource.metadata = metadata; + self.resources.update(resource).await?; + report.metadata_updated_count += 1; + } + } + report.unchanged_count += 1; + count_resource_type(report, resource_type); + return Ok(()); + } + + let object_id = self.cas.store(&bytes).await?; + let resource = Resource { + id: resource_id, + local_path: PathBuf::from(&manifest_entry.destination), + entry: ResourceEntry { + path: manifest_entry.destination.clone(), + hash: object_id.clone(), + size: manifest_entry.bytes, + resource_type, + address: None, + dependencies: Vec::new(), + crc: None, + }, + metadata, + }; + if let Err(error) = self.resources.add(resource).await { + let _ = self.cas.remove_reference(&object_id).await; + return Err(error); + } + + if let Some(previous) = previous { + if previous.entry.hash != object_id { + match self.cas.remove_reference(&previous.entry.hash).await { + Ok(_) => {} + Err(error) => report.warnings.push(format!( + "旧 CAS 引用清理失败 {}:{}", + previous.entry.hash, error + )), + } + } + } + report.imported_count += 1; + count_resource_type(report, resource_type); + Ok(()) + } +} + +fn parse_entries_by_destination( + cache: &OfficialParseCache, +) -> BTreeMap> { + let mut by_destination: BTreeMap> = BTreeMap::new(); + for entry in cache.entries.values() { + by_destination + .entry(entry.destination.clone()) + .or_default() + .push(entry); + } + by_destination +} + +fn metadata_for_manifest_entry( + official_release_id: Option<&str>, + manifest_entry: &OfficialDownloadManifestEntry, + parse_entries: &[&OfficialParseCacheEntry], +) -> ResourceMetadata { + let mut metadata = ResourceMetadata { + official_release_id: official_release_id.map(ToOwned::to_owned), + platform: platform_for_destination(&manifest_entry.destination), + bundle_path: Some(manifest_entry.destination.clone()), + ..ResourceMetadata::default() + }; + + let mut archive_entries = BTreeSet::new(); + let mut parse_statuses = BTreeSet::new(); + let mut unity_versions = BTreeSet::new(); + let mut text_assets = BTreeSet::new(); + let mut text_unit_formats = BTreeSet::new(); + + for entry in parse_entries { + if let Some(archive_entry) = entry.archive_entry.as_ref() { + archive_entries.insert(archive_entry.clone()); + } + parse_statuses.insert(parse_status_label(entry.status).to_string()); + if let Some(unity_version) = entry.unity_version.as_ref() { + unity_versions.insert(unity_version.clone()); + } + metadata.unityfs_file_count += entry.file_count as u64; + metadata.serialized_file_count += entry.serialized_file_count as u64; + metadata.text_asset_count += entry.text_asset_count as u64; + metadata.text_unit_count += entry.text_unit_count as u64; + metadata.text_unit_error_count += entry.text_unit_error_count as u64; + text_assets.extend(entry.text_assets.iter().cloned()); + text_unit_formats.extend(entry.text_unit_formats.iter().cloned()); + } + + metadata.archive_entries = archive_entries.into_iter().collect(); + metadata.parse_statuses = parse_statuses.into_iter().collect(); + metadata.unity_versions = unity_versions.into_iter().collect(); + metadata.text_assets = text_assets.into_iter().collect(); + metadata.text_unit_formats = text_unit_formats.into_iter().collect(); + metadata +} + +fn parse_status_label(status: OfficialParseStatus) -> &'static str { + match status { + OfficialParseStatus::Parsed => "parsed", + OfficialParseStatus::SkippedUnsupported => "skipped_unsupported", + OfficialParseStatus::Failed => "failed", + } +} + +fn platform_for_destination(destination: &str) -> Option { + let normalized = destination.replace('\\', "/").to_ascii_lowercase(); + if normalized.contains("windows") || normalized.contains("/win/") { + Some("windows".to_string()) + } else if normalized.contains("android") { + Some("android".to_string()) + } else { + None + } +} + +fn release_id_from_root(release_root: &Path) -> Option { + release_root + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty() && *name != "current") + .map(ToOwned::to_owned) +} + +fn resource_id_for_destination(destination: &str) -> String { + format!("official/{}", destination.replace('\\', "/")) +} + +fn resource_type_for_path(path: &str) -> ResourceType { + let normalized = path.replace('\\', "/").to_ascii_lowercase(); + if normalized.ends_with(".bundle") || normalized.ends_with(".unity3d") { + ResourceType::AssetBundle + } else if normalized.contains("tablebundles/") { + ResourceType::TableBundle + } else if normalized.contains("textassets/") + || matches!( + normalized.rsplit('.').next(), + Some("txt" | "csv" | "json" | "xml" | "yaml" | "yml") + ) + { + ResourceType::TextAsset + } else if normalized.contains("mediaresources/") + || matches!( + normalized.rsplit('.').next(), + Some("acb" | "awb" | "jpg" | "jpeg" | "mp3" | "mp4" | "ogg" | "png" | "wav" | "webp") + ) + { + ResourceType::Media + } else if normalized.contains("catalog") || normalized.ends_with(".hash") { + ResourceType::Manifest + } else { + ResourceType::Other + } +} + +fn count_resource_type(report: &mut OfficialReleaseImportReport, resource_type: ResourceType) { + match resource_type { + ResourceType::AssetBundle => report.asset_bundle_count += 1, + ResourceType::TextAsset => report.text_asset_count += 1, + ResourceType::TableBundle => report.table_count += 1, + ResourceType::Media => report.media_count += 1, + ResourceType::Manifest | ResourceType::Other => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FileSystemCasRepository, InMemoryResourceRepository}; + use crate::{ + OfficialParseSourceFingerprint, OfficialParseSourceKind, OFFICIAL_PARSE_CACHE_VERSION, + }; + use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; + use std::collections::BTreeMap; + use tempfile::TempDir; + + fn write_manifest(root: &Path, destination: &str, bytes: &[u8]) { + let path = root.join(destination); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + let url = format!("https://prod-clientpatch.bluearchiveyostar.com/r93/{destination}"); + let entry = OfficialDownloadManifestEntry { + url: url.clone(), + destination: destination.to_string(), + bytes: bytes.len() as u64, + blake3: blake3::hash(bytes).to_hex().to_string(), + }; + let manifest = serde_json::json!({ + "version": 1, + "entries": BTreeMap::from([(url, entry)]), + }); + std::fs::write( + root.join("official-download-manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + } + + fn write_parse_cache(root: &Path, destination: &str, bytes: &[u8]) { + let source_url = + format!("https://prod-clientpatch.bluearchiveyostar.com/r93/{destination}"); + let entry = OfficialParseCacheEntry { + key: format!("direct:{source_url}"), + source_url: source_url.clone(), + destination: destination.to_string(), + archive_entry: None, + source_kind: OfficialParseSourceKind::DirectBundle, + fingerprint: OfficialParseSourceFingerprint { + source_url, + destination: destination.to_string(), + bytes: bytes.len() as u64, + blake3: blake3::hash(bytes).to_hex().to_string(), + }, + status: OfficialParseStatus::Parsed, + reused_from_previous_cache: false, + unity_version: Some("2021.3.56f2".to_string()), + file_count: 1, + serialized_file_count: 1, + text_asset_count: 1, + text_assets: vec!["Scenario".to_string()], + serialized_parse_error_count: 0, + text_unit_count: 2, + text_unit_formats: vec!["json".to_string(), "plain".to_string()], + skipped_binary_text_asset_count: 0, + text_unit_error_count: 1, + error: None, + }; + let cache = OfficialParseCache { + version: OFFICIAL_PARSE_CACHE_VERSION, + generated_unix_seconds: 123, + summary: OfficialParseSummary { + manifest_entry_count: 1, + cache_entry_count: 1, + candidate_file_count: 1, + zip_entry_count: 0, + skipped_unchanged_count: 0, + parsed_bundle_count: 1, + unsupported_count: 0, + failed_count: 0, + text_asset_count: 1, + text_unit_count: 2, + skipped_binary_text_asset_count: 0, + text_unit_error_count: 1, + }, + entries: BTreeMap::from([(entry.key.clone(), entry)]), + }; + std::fs::write( + root.join("official-parse-cache.json"), + serde_json::to_vec(&cache).unwrap(), + ) + .unwrap(); + } + + #[tokio::test] + async fn imports_verified_release_idempotently() { + let temp = TempDir::new().unwrap(); + write_manifest(temp.path(), "TableBundles/TableCatalog.bytes", b"catalog"); + let cas = FileSystemCasRepository::new(temp.path().join("cas")); + let resources = InMemoryResourceRepository::new(); + let service = OfficialReleaseImportService::new(&cas, &resources); + let config = OfficialReleaseImportConfig::new(temp.path()); + + let first = service.import_release(&config).await.unwrap(); + let second = service.import_release(&config).await.unwrap(); + + assert_eq!(first.imported_count, 1); + assert_eq!(second.imported_count, 0); + assert_eq!(second.unchanged_count, 1); + assert_eq!( + resources + .count(ResourceQuery::by_type(ResourceType::TableBundle)) + .await + .unwrap(), + 1 + ); + let id = resource_id_for_destination("TableBundles/TableCatalog.bytes"); + let resource = resources.find_by_id(&id).await.unwrap(); + assert_eq!( + cas.get_reference_count(&resource.entry.hash).await.unwrap(), + 1 + ); + } + + #[tokio::test] + async fn imports_parse_cache_metadata_into_resource_index() { + let temp = TempDir::new().unwrap(); + let destination = "Windows/Bundles/scenario.bundle"; + let bytes = b"bundle-bytes"; + write_manifest(temp.path(), destination, bytes); + write_parse_cache(temp.path(), destination, bytes); + let cas = FileSystemCasRepository::new(temp.path().join("cas")); + let resources = InMemoryResourceRepository::new(); + let service = OfficialReleaseImportService::new(&cas, &resources); + + let report = service + .import_release( + &OfficialReleaseImportConfig::new(temp.path()) + .with_official_release_id("release-current"), + ) + .await + .unwrap(); + + assert_eq!(report.imported_count, 1); + assert_eq!(report.parse_summary.as_ref().unwrap().text_unit_count, 2); + let id = resource_id_for_destination(destination); + let resource = resources.find_by_id(&id).await.unwrap(); + assert_eq!( + resource.metadata.official_release_id.as_deref(), + Some("release-current") + ); + assert_eq!(resource.metadata.platform.as_deref(), Some("windows")); + assert_eq!(resource.metadata.bundle_path.as_deref(), Some(destination)); + assert_eq!(resource.metadata.parse_statuses, vec!["parsed".to_string()]); + assert_eq!( + resource.metadata.unity_versions, + vec!["2021.3.56f2".to_string()] + ); + assert_eq!(resource.metadata.text_assets, vec!["Scenario".to_string()]); + assert_eq!(resource.metadata.text_unit_count, 2); + assert_eq!( + resource.metadata.text_unit_formats, + vec!["json".to_string(), "plain".to_string()] + ); + assert_eq!(resource.metadata.text_unit_error_count, 1); + } + + #[tokio::test] + async fn rejects_manifest_hash_mismatch_before_cas_write() { + let temp = TempDir::new().unwrap(); + write_manifest(temp.path(), "TextAssets/dialogue.txt", b"original"); + let path = temp.path().join("TextAssets/dialogue.txt"); + std::fs::write(&path, b"tampered").unwrap(); + let cas = FileSystemCasRepository::new(temp.path().join("cas")); + let resources = InMemoryResourceRepository::new(); + let service = OfficialReleaseImportService::new(&cas, &resources); + + let error = service + .import_release(&OfficialReleaseImportConfig::new(temp.path())) + .await + .unwrap_err(); + + assert!(error.to_string().contains("BLAKE3")); + assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0); + } +} diff --git a/infrastructure/src/official_textunit_queue.rs b/infrastructure/src/official_textunit_queue.rs new file mode 100644 index 0000000..cb5ceb8 --- /dev/null +++ b/infrastructure/src/official_textunit_queue.rs @@ -0,0 +1,834 @@ +//! Incremental TextUnit task and Crowdin offline queues for official releases. +//! +//! The queue is derived only from a verified official release, its +//! `official-resource-changes.json`, and its `official-parse-cache.json`. +//! It does not call Crowdin or any network service. + +use crate::official_changes::{ + read_resource_change_set_at, OfficialResourceChange, OfficialResourceChangeKind, + OfficialResourceChangeSet, TranslationHandoffProvider, TranslationHandoffStatus, +}; +use crate::official_parse::{ + read_parse_cache_at, OfficialParseCache, OfficialParseCacheEntry, OfficialParseSourceKind, + OfficialParseStatus, +}; +use crate::path_security::{ + ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic, + STATE_FILE_MODE, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Current TextUnit task-queue schema version. +pub const OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION: u32 = 1; +/// File name stored under a published official release root. +pub const OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE: &str = "official-textunit-tasks.json"; +/// Current Crowdin TextUnit queue schema version. +pub const CROWDIN_TEXTUNIT_QUEUE_VERSION: u32 = 1; +/// File name stored under a published official release root. +pub const CROWDIN_TEXTUNIT_QUEUE_FILE: &str = "crowdin-textunit-queue.json"; + +/// Processing status for one incremental TextUnit task candidate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OfficialTextUnitTaskStatus { + /// The task has parse metadata and at least one TextUnit. + QueuedOffline, + /// No parse-cache entry exists for this changed resource yet. + SkippedNoParseEntry, + /// The resource parsed successfully, but did not produce TextUnits. + SkippedNoTextUnit, + /// Parse cache says the candidate failed to parse. + SkippedParseFailed, + /// Parse cache says the candidate is unsupported. + SkippedUnsupported, +} + +impl OfficialTextUnitTaskStatus { + /// Returns the stable status label. + pub fn as_str(self) -> &'static str { + match self { + Self::QueuedOffline => "queued_offline", + Self::SkippedNoParseEntry => "skipped_no_parse_entry", + Self::SkippedNoTextUnit => "skipped_no_text_unit", + Self::SkippedParseFailed => "skipped_parse_failed", + Self::SkippedUnsupported => "skipped_unsupported", + } + } +} + +/// One parse-cache-backed incremental task. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitTask { + /// Stable task ID. + pub task_id: String, + /// Current official release ID. + pub official_release_id: String, + /// Relative resource path under the official release root. + pub destination: String, + /// Change kind that caused this task candidate. + pub change_kind: OfficialResourceChangeKind, + /// Current official URL. + pub url: String, + /// Verified byte count. + pub bytes: u64, + /// Verified BLAKE3 digest. + pub blake3: String, + /// Parse-cache entry key, when available. + pub parse_entry_key: Option, + /// ZIP/archive entry containing the parsed bundle, when available. + pub archive_entry: Option, + /// Parse-cache source kind, when available. + pub source_kind: Option, + /// Parse status, when available. + pub parse_status: Option, + /// Number of TextAsset objects in this parse entry. + pub text_asset_count: usize, + /// TextAsset names in this parse entry. + pub text_assets: Vec, + /// Number of TextUnits in this parse entry. + pub text_unit_count: usize, + /// TextUnit format labels. + pub text_unit_formats: Vec, + /// Non-fatal TextUnit extraction diagnostic count. + pub text_unit_error_count: usize, + /// Queue status. + pub status: OfficialTextUnitTaskStatus, + /// Diagnostic reason for skipped tasks. + pub reason: Option, +} + +/// Aggregate counters for an incremental TextUnit task queue. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitTaskSummary { + /// Number of added/modified resources considered. + pub resource_candidate_count: usize, + /// Number of parse-cache entries inspected for those resources. + pub parse_entry_count: usize, + /// Number of tasks queued for offline translation. + pub queued_task_count: usize, + /// Number of changed resources with no parse-cache entry. + pub skipped_no_parse_entry_count: usize, + /// Number of parse entries with zero TextUnits. + pub skipped_no_text_unit_count: usize, + /// Number of failed parse entries. + pub skipped_parse_failed_count: usize, + /// Number of unsupported parse entries. + pub skipped_unsupported_count: usize, + /// Total queued TextUnit count. + pub text_unit_count: usize, +} + +impl OfficialTextUnitTaskSummary { + fn record(&mut self, task: &OfficialTextUnitTask) { + match task.status { + OfficialTextUnitTaskStatus::QueuedOffline => { + self.queued_task_count += 1; + self.text_unit_count += task.text_unit_count; + } + OfficialTextUnitTaskStatus::SkippedNoParseEntry => { + self.skipped_no_parse_entry_count += 1; + } + OfficialTextUnitTaskStatus::SkippedNoTextUnit => { + self.skipped_no_text_unit_count += 1; + } + OfficialTextUnitTaskStatus::SkippedParseFailed => { + self.skipped_parse_failed_count += 1; + } + OfficialTextUnitTaskStatus::SkippedUnsupported => { + self.skipped_unsupported_count += 1; + } + } + } +} + +/// Durable incremental TextUnit task queue for one official release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitTaskQueue { + /// Queue schema version. + #[serde(default = "default_textunit_task_queue_version")] + pub queue_version: u32, + /// Current official release ID. + pub official_release_id: String, + /// Previous official release ID, when known. + pub previous_release_id: Option, + /// Generation time as Unix seconds. + pub generated_unix_seconds: u64, + /// Current official release root. + pub current_resource_root: PathBuf, + /// Queue summary. + pub summary: OfficialTextUnitTaskSummary, + /// All task candidates, including skipped diagnostics. + pub tasks: Vec, +} + +impl OfficialTextUnitTaskQueue { + /// Builds a task queue from an official change set and parse cache. + pub fn from_change_set_and_parse_cache( + change_set: &OfficialResourceChangeSet, + parse_cache: &OfficialParseCache, + ) -> Self { + let parse_entries = parse_entries_by_destination(parse_cache); + let mut summary = OfficialTextUnitTaskSummary { + resource_candidate_count: change_set.parse_candidates().len(), + ..OfficialTextUnitTaskSummary::default() + }; + let mut tasks = Vec::new(); + + for change in change_set.parse_candidates() { + let entries = parse_entries + .get(&change.destination) + .map(Vec::as_slice) + .unwrap_or(&[]); + if entries.is_empty() { + let task = skipped_no_parse_entry_task(change_set, change); + summary.record(&task); + tasks.push(task); + continue; + } + + summary.parse_entry_count += entries.len(); + for entry in entries { + let task = task_from_parse_entry(change_set, change, entry); + summary.record(&task); + tasks.push(task); + } + } + + Self { + queue_version: OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, + official_release_id: change_set.official_release_id.clone(), + previous_release_id: change_set.previous_release_id.clone(), + generated_unix_seconds: unix_seconds_now(), + current_resource_root: change_set.current_resource_root.clone(), + summary, + tasks, + } + } + + fn matches_derivation(&self, expected: &Self) -> bool { + self.official_release_id == expected.official_release_id + && self.previous_release_id == expected.previous_release_id + && self.current_resource_root == expected.current_resource_root + && self.summary == expected.summary + && self.tasks == expected.tasks + } +} + +/// One Crowdin offline queue item referencing a TextUnit task. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrowdinTextUnitQueueItem { + /// Stable TextUnit task ID. + pub task_id: String, + /// Relative resource path under the official release root. + pub destination: String, + /// ZIP/archive entry, when applicable. + pub archive_entry: Option, + /// Number of queued TextUnits. + pub text_unit_count: usize, + /// TextUnit format labels. + pub text_unit_formats: Vec, +} + +/// Crowdin offline queue derived from queued TextUnit tasks. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrowdinTextUnitQueue { + /// Queue schema version. + #[serde(default = "default_crowdin_textunit_queue_version")] + pub queue_version: u32, + /// Provider reserved for this queue. + pub provider: TranslationHandoffProvider, + /// Queue status. + pub status: TranslationHandoffStatus, + /// Current official release ID. + pub official_release_id: String, + /// Generation time as Unix seconds. + pub generated_unix_seconds: u64, + /// Path to the source TextUnit task queue. + pub textunit_task_queue_path: PathBuf, + /// Number of queued task items. + pub task_count: usize, + /// Total queued TextUnit count. + pub text_unit_count: usize, + /// Queued items for the future Crowdin worker. + pub items: Vec, +} + +impl CrowdinTextUnitQueue { + /// Builds a Crowdin offline queue from queued TextUnit tasks. + pub fn from_textunit_task_queue( + task_queue_path: PathBuf, + task_queue: &OfficialTextUnitTaskQueue, + ) -> Self { + let items = task_queue + .tasks + .iter() + .filter(|task| task.status == OfficialTextUnitTaskStatus::QueuedOffline) + .map(|task| CrowdinTextUnitQueueItem { + task_id: task.task_id.clone(), + destination: task.destination.clone(), + archive_entry: task.archive_entry.clone(), + text_unit_count: task.text_unit_count, + text_unit_formats: task.text_unit_formats.clone(), + }) + .collect::>(); + let text_unit_count = items.iter().map(|item| item.text_unit_count).sum(); + + Self { + queue_version: CROWDIN_TEXTUNIT_QUEUE_VERSION, + provider: TranslationHandoffProvider::Crowdin, + status: TranslationHandoffStatus::QueuedOffline, + official_release_id: task_queue.official_release_id.clone(), + generated_unix_seconds: unix_seconds_now(), + textunit_task_queue_path: task_queue_path, + task_count: items.len(), + text_unit_count, + items, + } + } + + fn matches_derivation(&self, expected: &Self) -> bool { + self.provider == expected.provider + && self.status == expected.status + && self.official_release_id == expected.official_release_id + && self.textunit_task_queue_path == expected.textunit_task_queue_path + && self.task_count == expected.task_count + && self.text_unit_count == expected.text_unit_count + && self.items == expected.items + } +} + +/// Paths and summary produced after writing incremental TextUnit queues. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialTextUnitQueueReport { + /// Path to `official-textunit-tasks.json`. + pub textunit_task_queue_path: PathBuf, + /// Path to `crowdin-textunit-queue.json`. + pub crowdin_textunit_queue_path: PathBuf, + /// Aggregate queue counters. + pub summary: OfficialTextUnitTaskSummary, +} + +/// Reads change/parse derived queues from a release root and writes them back. +pub fn write_official_textunit_queues( + resource_root: &Path, +) -> Result { + let change_set = read_resource_change_set_at(resource_root)?.ok_or_else(|| { + format!( + "缺少官方资源变更集,无法生成 TextUnit 队列:{}", + resource_root.display() + ) + })?; + let parse_cache = read_parse_cache_at(resource_root)?.ok_or_else(|| { + format!( + "缺少官方解析缓存,无法生成 TextUnit 队列:{}", + resource_root.display() + ) + })?; + let task_queue = + OfficialTextUnitTaskQueue::from_change_set_and_parse_cache(&change_set, &parse_cache); + write_textunit_task_queue_at(resource_root, &task_queue)?; + + let task_queue_path = resource_root.join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + let crowdin_queue = + CrowdinTextUnitQueue::from_textunit_task_queue(task_queue_path.clone(), &task_queue); + write_crowdin_textunit_queue_at(resource_root, &crowdin_queue)?; + + Ok(OfficialTextUnitQueueReport { + textunit_task_queue_path: task_queue_path, + crowdin_textunit_queue_path: resource_root.join(CROWDIN_TEXTUNIT_QUEUE_FILE), + summary: task_queue.summary, + }) +} + +/// Reads a generated TextUnit task queue from a release root. +pub fn read_textunit_task_queue_at( + resource_root: &Path, +) -> Result, String> { + let path = resource_root.join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + let Some(bytes) = read_file_no_symlink(&path, "官方 TextUnit 任务队列")? else { + return Ok(None); + }; + let queue: OfficialTextUnitTaskQueue = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析官方 TextUnit 任务队列失败 {}:{error}", path.display()))?; + if queue.queue_version != OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION { + return Err(format!( + "不支持的官方 TextUnit 任务队列版本 {},文件 {}", + queue.queue_version, + path.display() + )); + } + Ok(Some(queue)) +} + +/// Returns whether the persisted TextUnit queue still matches current inputs. +pub fn is_textunit_task_queue_current( + resource_root: &Path, + queue: &OfficialTextUnitTaskQueue, +) -> Result { + let Some(change_set) = read_resource_change_set_at(resource_root)? else { + return Ok(false); + }; + let Some(parse_cache) = read_parse_cache_at(resource_root)? else { + return Ok(false); + }; + let expected = + OfficialTextUnitTaskQueue::from_change_set_and_parse_cache(&change_set, &parse_cache); + Ok(queue.matches_derivation(&expected)) +} + +/// Writes a TextUnit task queue under a release root. +pub fn write_textunit_task_queue_at( + resource_root: &Path, + queue: &OfficialTextUnitTaskQueue, +) -> Result<(), String> { + let path = resource_root.join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + ensure_path_within_root(resource_root, &path)?; + ensure_safe_file_target(resource_root, &path, "官方 TextUnit 任务队列")?; + let bytes = serde_json::to_vec_pretty(queue) + .map_err(|error| format!("序列化官方 TextUnit 任务队列失败:{error}"))?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方 TextUnit 任务队列") +} + +/// Reads a generated Crowdin offline TextUnit queue from a release root. +pub fn read_crowdin_textunit_queue_at( + resource_root: &Path, +) -> Result, String> { + let path = resource_root.join(CROWDIN_TEXTUNIT_QUEUE_FILE); + let Some(bytes) = read_file_no_symlink(&path, "Crowdin TextUnit 离线队列")? else { + return Ok(None); + }; + let queue: CrowdinTextUnitQueue = serde_json::from_slice(&bytes).map_err(|error| { + format!( + "解析 Crowdin TextUnit 离线队列失败 {}:{error}", + path.display() + ) + })?; + if queue.queue_version != CROWDIN_TEXTUNIT_QUEUE_VERSION { + return Err(format!( + "不支持的 Crowdin TextUnit 离线队列版本 {},文件 {}", + queue.queue_version, + path.display() + )); + } + Ok(Some(queue)) +} + +/// Returns whether the persisted Crowdin queue still matches a TextUnit queue. +pub fn is_crowdin_textunit_queue_current( + resource_root: &Path, + task_queue: &OfficialTextUnitTaskQueue, +) -> Result { + let Some(queue) = read_crowdin_textunit_queue_at(resource_root)? else { + return Ok(false); + }; + let expected = CrowdinTextUnitQueue::from_textunit_task_queue( + resource_root.join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE), + task_queue, + ); + Ok(queue.matches_derivation(&expected)) +} + +/// Writes a Crowdin offline TextUnit queue under a release root. +pub fn write_crowdin_textunit_queue_at( + resource_root: &Path, + queue: &CrowdinTextUnitQueue, +) -> Result<(), String> { + let path = resource_root.join(CROWDIN_TEXTUNIT_QUEUE_FILE); + ensure_path_within_root(resource_root, &path)?; + ensure_safe_file_target(resource_root, &path, "Crowdin TextUnit 离线队列")?; + let bytes = serde_json::to_vec_pretty(queue) + .map_err(|error| format!("序列化 Crowdin TextUnit 离线队列失败:{error}"))?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "Crowdin TextUnit 离线队列") +} + +fn parse_entries_by_destination( + cache: &OfficialParseCache, +) -> BTreeMap> { + let mut by_destination: BTreeMap> = BTreeMap::new(); + for entry in cache.entries.values() { + by_destination + .entry(entry.destination.clone()) + .or_default() + .push(entry); + } + by_destination +} + +fn skipped_no_parse_entry_task( + change_set: &OfficialResourceChangeSet, + change: &OfficialResourceChange, +) -> OfficialTextUnitTask { + let current = change + .current + .as_ref() + .expect("parse candidate has current"); + OfficialTextUnitTask { + task_id: task_id_for(&change_set.official_release_id, &change.destination, None), + official_release_id: change_set.official_release_id.clone(), + destination: change.destination.clone(), + change_kind: change.kind, + url: current.url.clone(), + bytes: current.bytes, + blake3: current.blake3.clone(), + parse_entry_key: None, + archive_entry: None, + source_kind: None, + parse_status: None, + text_asset_count: 0, + text_assets: Vec::new(), + text_unit_count: 0, + text_unit_formats: Vec::new(), + text_unit_error_count: 0, + status: OfficialTextUnitTaskStatus::SkippedNoParseEntry, + reason: Some("parse cache entry not found for changed resource".to_string()), + } +} + +fn task_from_parse_entry( + change_set: &OfficialResourceChangeSet, + change: &OfficialResourceChange, + entry: &OfficialParseCacheEntry, +) -> OfficialTextUnitTask { + let current = change + .current + .as_ref() + .expect("parse candidate has current"); + let (status, reason) = status_for_parse_entry(entry); + OfficialTextUnitTask { + task_id: task_id_for( + &change_set.official_release_id, + &change.destination, + entry.archive_entry.as_deref(), + ), + official_release_id: change_set.official_release_id.clone(), + destination: change.destination.clone(), + change_kind: change.kind, + url: current.url.clone(), + bytes: current.bytes, + blake3: current.blake3.clone(), + parse_entry_key: Some(entry.key.clone()), + archive_entry: entry.archive_entry.clone(), + source_kind: Some(entry.source_kind), + parse_status: Some(entry.status), + text_asset_count: entry.text_asset_count, + text_assets: entry.text_assets.clone(), + text_unit_count: entry.text_unit_count, + text_unit_formats: entry.text_unit_formats.clone(), + text_unit_error_count: entry.text_unit_error_count, + status, + reason, + } +} + +fn status_for_parse_entry( + entry: &OfficialParseCacheEntry, +) -> (OfficialTextUnitTaskStatus, Option) { + match entry.status { + OfficialParseStatus::Parsed if entry.text_unit_count > 0 => { + (OfficialTextUnitTaskStatus::QueuedOffline, None) + } + OfficialParseStatus::Parsed => ( + OfficialTextUnitTaskStatus::SkippedNoTextUnit, + Some("parsed resource has no TextUnit".to_string()), + ), + OfficialParseStatus::Failed => ( + OfficialTextUnitTaskStatus::SkippedParseFailed, + entry.error.clone(), + ), + OfficialParseStatus::SkippedUnsupported => ( + OfficialTextUnitTaskStatus::SkippedUnsupported, + entry.error.clone(), + ), + } +} + +fn task_id_for( + official_release_id: &str, + destination: &str, + archive_entry: Option<&str>, +) -> String { + match archive_entry { + Some(entry) => format!("textunit/{official_release_id}/{destination}#{entry}"), + None => format!("textunit/{official_release_id}/{destination}"), + } +} + +fn unix_seconds_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn default_textunit_task_queue_version() -> u32 { + OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION +} + +fn default_crowdin_textunit_queue_version() -> u32 { + CROWDIN_TEXTUNIT_QUEUE_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::official_changes::{ + OfficialResourceChangeSet, OfficialResourceDescriptor, OFFICIAL_RESOURCE_CHANGES_VERSION, + }; + use crate::official_parse::{ + OfficialParseCache, OfficialParseCacheEntry, OfficialParseSourceFingerprint, + OfficialParseSourceKind, OfficialParseSummary, OFFICIAL_PARSE_CACHE_VERSION, + }; + + fn change_set(root: &Path) -> OfficialResourceChangeSet { + OfficialResourceChangeSet { + change_set_version: OFFICIAL_RESOURCE_CHANGES_VERSION, + official_release_id: "release-new".to_string(), + previous_release_id: Some("release-old".to_string()), + generated_unix_seconds: 123, + previous_resource_root: None, + current_resource_root: root.to_path_buf(), + summary: crate::OfficialResourceChangeSummary { + previous_manifest_present: true, + previous_manifest_entry_count: 2, + current_manifest_entry_count: 3, + added_count: 2, + modified_count: 1, + removed_count: 1, + parse_candidate_count: 3, + translation_candidate_count: 3, + }, + changes: vec![ + change("Bundles/a.bundle", OfficialResourceChangeKind::Added, b"a"), + change( + "Bundles/b.bundle", + OfficialResourceChangeKind::Modified, + b"b", + ), + change("Bundles/c.bundle", OfficialResourceChangeKind::Added, b"c"), + crate::OfficialResourceChange { + destination: "Bundles/removed.bundle".to_string(), + kind: OfficialResourceChangeKind::Removed, + previous: Some(OfficialResourceDescriptor { + url: "https://old/removed".to_string(), + destination: "Bundles/removed.bundle".to_string(), + bytes: 1, + blake3: "old".to_string(), + }), + current: None, + parse_candidate: false, + translation_candidate: false, + }, + ], + } + } + + fn change( + destination: &str, + kind: OfficialResourceChangeKind, + bytes: &[u8], + ) -> crate::OfficialResourceChange { + crate::OfficialResourceChange { + destination: destination.to_string(), + kind, + previous: None, + current: Some(OfficialResourceDescriptor { + url: format!("https://new/{destination}"), + destination: destination.to_string(), + bytes: bytes.len() as u64, + blake3: blake3::hash(bytes).to_hex().to_string(), + }), + parse_candidate: true, + translation_candidate: true, + } + } + + fn parse_cache() -> OfficialParseCache { + OfficialParseCache { + version: OFFICIAL_PARSE_CACHE_VERSION, + generated_unix_seconds: 124, + summary: OfficialParseSummary { + manifest_entry_count: 3, + cache_entry_count: 2, + candidate_file_count: 2, + zip_entry_count: 0, + skipped_unchanged_count: 0, + parsed_bundle_count: 1, + unsupported_count: 1, + failed_count: 0, + text_asset_count: 1, + text_unit_count: 2, + skipped_binary_text_asset_count: 0, + text_unit_error_count: 0, + }, + entries: BTreeMap::from([ + ( + "direct:a".to_string(), + parse_entry( + "direct:a", + "Bundles/a.bundle", + OfficialParseStatus::Parsed, + 2, + ), + ), + ( + "direct:b".to_string(), + parse_entry( + "direct:b", + "Bundles/b.bundle", + OfficialParseStatus::SkippedUnsupported, + 0, + ), + ), + ]), + } + } + + fn parse_entry( + key: &str, + destination: &str, + status: OfficialParseStatus, + text_unit_count: usize, + ) -> OfficialParseCacheEntry { + OfficialParseCacheEntry { + key: key.to_string(), + source_url: format!("https://new/{destination}"), + destination: destination.to_string(), + archive_entry: None, + source_kind: OfficialParseSourceKind::DirectBundle, + fingerprint: OfficialParseSourceFingerprint { + source_url: format!("https://new/{destination}"), + destination: destination.to_string(), + bytes: 1, + blake3: "hash".to_string(), + }, + status, + reused_from_previous_cache: false, + unity_version: Some("2021.3.56f2".to_string()), + file_count: 1, + serialized_file_count: 1, + text_asset_count: usize::from(text_unit_count > 0), + text_assets: if text_unit_count > 0 { + vec!["Scenario".to_string()] + } else { + Vec::new() + }, + serialized_parse_error_count: 0, + text_unit_count, + text_unit_formats: if text_unit_count > 0 { + vec!["plain".to_string()] + } else { + Vec::new() + }, + skipped_binary_text_asset_count: 0, + text_unit_error_count: 0, + error: (status != OfficialParseStatus::Parsed).then(|| "unsupported".to_string()), + } + } + + #[test] + fn textunit_queue_uses_only_added_and_modified_parse_candidates() { + let temp = tempfile::TempDir::new().unwrap(); + let queue = OfficialTextUnitTaskQueue::from_change_set_and_parse_cache( + &change_set(temp.path()), + &parse_cache(), + ); + + assert_eq!(queue.summary.resource_candidate_count, 3); + assert_eq!(queue.summary.parse_entry_count, 2); + assert_eq!(queue.summary.queued_task_count, 1); + assert_eq!(queue.summary.skipped_unsupported_count, 1); + assert_eq!(queue.summary.skipped_no_parse_entry_count, 1); + assert_eq!(queue.summary.text_unit_count, 2); + assert_eq!( + queue + .tasks + .iter() + .filter(|task| task.status == OfficialTextUnitTaskStatus::QueuedOffline) + .map(|task| task.destination.as_str()) + .collect::>(), + vec!["Bundles/a.bundle"] + ); + } + + #[test] + fn crowdin_queue_contains_only_queued_textunit_tasks() { + let temp = tempfile::TempDir::new().unwrap(); + let queue = OfficialTextUnitTaskQueue::from_change_set_and_parse_cache( + &change_set(temp.path()), + &parse_cache(), + ); + let crowdin = CrowdinTextUnitQueue::from_textunit_task_queue( + temp.path().join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE), + &queue, + ); + + assert_eq!(crowdin.provider, TranslationHandoffProvider::Crowdin); + assert_eq!(crowdin.status, TranslationHandoffStatus::QueuedOffline); + assert_eq!(crowdin.task_count, 1); + assert_eq!(crowdin.text_unit_count, 2); + assert_eq!(crowdin.items[0].destination, "Bundles/a.bundle"); + } + + #[test] + fn write_textunit_queues_persists_files() { + let temp = tempfile::TempDir::new().unwrap(); + crate::write_resource_change_set_at(temp.path(), &change_set(temp.path())).unwrap(); + crate::write_parse_cache_at(temp.path(), &parse_cache()).unwrap(); + + let report = write_official_textunit_queues(temp.path()).unwrap(); + + assert_eq!(report.summary.queued_task_count, 1); + assert!(report.textunit_task_queue_path.is_file()); + assert!(report.crowdin_textunit_queue_path.is_file()); + let persisted = read_textunit_task_queue_at(temp.path()).unwrap().unwrap(); + assert_eq!(persisted.summary.text_unit_count, 2); + } + + #[test] + fn textunit_queue_current_detects_parse_cache_changes() { + let temp = tempfile::TempDir::new().unwrap(); + crate::write_resource_change_set_at(temp.path(), &change_set(temp.path())).unwrap(); + crate::write_parse_cache_at(temp.path(), &parse_cache()).unwrap(); + write_official_textunit_queues(temp.path()).unwrap(); + + let queue = read_textunit_task_queue_at(temp.path()).unwrap().unwrap(); + assert!(is_textunit_task_queue_current(temp.path(), &queue).unwrap()); + + let mut changed_cache = parse_cache(); + changed_cache.entries.insert( + "direct:c".to_string(), + parse_entry( + "direct:c", + "Bundles/c.bundle", + OfficialParseStatus::Parsed, + 1, + ), + ); + crate::write_parse_cache_at(temp.path(), &changed_cache).unwrap(); + + assert!(!is_textunit_task_queue_current(temp.path(), &queue).unwrap()); + } + + #[test] + fn crowdin_queue_current_requires_matching_file() { + let temp = tempfile::TempDir::new().unwrap(); + let queue = OfficialTextUnitTaskQueue::from_change_set_and_parse_cache( + &change_set(temp.path()), + &parse_cache(), + ); + + write_textunit_task_queue_at(temp.path(), &queue).unwrap(); + assert!(!is_crowdin_textunit_queue_current(temp.path(), &queue).unwrap()); + + let crowdin = CrowdinTextUnitQueue::from_textunit_task_queue( + temp.path().join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE), + &queue, + ); + write_crowdin_textunit_queue_at(temp.path(), &crowdin).unwrap(); + + assert!(is_crowdin_textunit_queue_current(temp.path(), &queue).unwrap()); + } +} diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 8116250..de42461 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -6,6 +6,24 @@ //! local manifest, repair/download when needed, then return a structured report. use crate::curl_transfer::{resolve_curl_proxy, CurlProxyConfig}; +use crate::localized_patch::{ + read_localized_version_state, LOCALIZED_CURRENT_LINK, LOCALIZED_VERSIONS_DIR, +}; +use crate::official_changes::{ + write_official_resource_change_handoff, OfficialResourceChangeHandoffReport, + OfficialResourceChangeSummary, +}; +use crate::official_game_main_config::{ + resolve_game_main_config_source, OfficialGameMainConfigSelectedSource, + OfficialGameMainConfigSourceKind, +}; +use crate::official_repository::{ + OfficialReleaseImportConfig, OfficialReleaseImportReport, OfficialReleaseImportService, +}; +use crate::official_textunit_queue::{ + is_crowdin_textunit_queue_current, is_textunit_task_queue_current, read_textunit_task_queue_at, + write_official_textunit_queues, OfficialTextUnitQueueReport, OfficialTextUnitTaskSummary, +}; use crate::path_security::{ 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, @@ -15,14 +33,18 @@ use crate::{ changed_endpoint_urls, default_official_platforms, DownloadError, OfficialGameMainConfigBootstrapService, OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullProgress, OfficialResourcePullProgressKind, - OfficialResourcePullService, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, - YostarJpLauncherRemoteManifest, + OfficialResourcePullService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, + YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, }; -use crate::{OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary}; +use crate::{ + read_parse_cache_at, OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary, +}; +use crate::{FileSystemCasRepository, SqliteResourceRepository}; use bat_adapters::official::game_main_config::YostarJpGameMainConfig; use bat_adapters::official::inventory::{ YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory, }; +use bat_adapters::official::launcher::YostarJpLauncherManifestFile; use bat_adapters::official::yostar_jp::{ server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot, @@ -41,6 +63,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2; /// Current official bootstrap cache schema version. pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1; +/// Current official launcher bootstrap artifact schema version. +pub const OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION: u32 = 1; /// Current official version-state schema version. pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1; const OFFICIAL_CURRENT_LINK: &str = "current"; @@ -48,6 +72,8 @@ const OFFICIAL_VERSIONS_DIR: &str = "versions"; const OFFICIAL_STAGING_DIR: &str = ".staging"; const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json"; const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json"; +const OFFICIAL_LAUNCHER_BOOTSTRAP_FILE: &str = "official-launcher-bootstrap.json"; +const OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE: &str = "official-launcher-bootstrap.pending.json"; const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json"; /// Server-info input for an official update run. @@ -99,6 +125,14 @@ pub struct OfficialUpdateConfig { pub audit_local: bool, /// Repair local files when the local manifest audit fails. pub repair: bool, + /// Import a verified official release into CAS + ResourceRepository. + pub import_repository: bool, + /// Optional CAS root for official release imports. Defaults under + /// `output_root` so official bytes and derived index stay isolated. + pub import_cas_root: Option, + /// Optional SQLite resource index path for official release imports. + /// Defaults under `output_root`. + pub import_resource_repository_path: Option, } impl Default for OfficialUpdateConfig { @@ -121,6 +155,9 @@ impl Default for OfficialUpdateConfig { force: false, audit_local: true, repair: true, + import_repository: false, + import_cas_root: None, + import_resource_repository_path: None, } } } @@ -147,6 +184,21 @@ impl OfficialUpdateConfig { pub fn lock_path(&self) -> PathBuf { self.output_root.join(".official-sync.lock") } + + /// Returns the CAS root used by the optional official release importer. + pub fn effective_import_cas_root(&self) -> PathBuf { + self.import_cas_root + .clone() + .unwrap_or_else(|| self.output_root.join(".cas")) + } + + /// Returns the SQLite resource index path used by the optional official + /// release importer. + pub fn effective_import_resource_repository_path(&self) -> PathBuf { + self.import_resource_repository_path + .clone() + .unwrap_or_else(|| self.output_root.join("resources.sqlite")) + } } /// Status of one official update execution. @@ -157,6 +209,9 @@ pub enum OfficialUpdateStatus { UpToDate, /// Dry-run detected that a download would occur. WouldDownload, + /// Official launcher/server-info is ahead of the client-patch CDN; keep the + /// existing release and check again later. + WaitingForOfficialResources, /// Resources were downloaded or repaired. Downloaded, } @@ -167,6 +222,7 @@ impl OfficialUpdateStatus { match self { Self::UpToDate => "up_to_date", Self::WouldDownload => "would_download", + Self::WaitingForOfficialResources => "waiting_for_official_resources", Self::Downloaded => "downloaded", } } @@ -356,6 +412,37 @@ pub struct OfficialEndpointMarkerSnapshot { pub value: String, } +/// Official endpoint that was advertised by launcher/server-info but was not +/// yet readable from the client-patch CDN. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialUnavailableEndpoint { + /// Endpoint kind. + pub kind: YostarJpResourceEndpointKind, + /// Platform for platform-specific endpoints. + pub platform: Option, + /// Official URL that was probed. + pub url: String, + /// Stable error-code kind from the failed fetch. + pub error_kind: String, + /// HTTP status when curl observed one. + pub http_status: Option, + /// Human-readable failure detail. + pub error: String, +} + +impl OfficialUnavailableEndpoint { + fn from_fetch_error(endpoint: &YostarJpResourceEndpoint, error: &DownloadError) -> Self { + Self { + kind: endpoint.kind, + platform: endpoint.platform, + url: endpoint.url.clone(), + error_kind: error.code().kind().to_string(), + http_status: http_status_from_error_message(&error.to_string()), + error: error.to_string(), + } + } +} + /// Role of a fetched marker endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OfficialEndpointMarkerRole { @@ -386,6 +473,9 @@ pub struct LauncherMetadataSnapshot { pub manifest_source: Option, /// Number of files in the remote launcher manifest. pub manifest_file_count: usize, + /// BLAKE3 digest over the remote launcher manifest file list. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_files_blake3: Option, } /// Summary of the decrypted GameMainConfig used by auto-discovery. @@ -397,6 +487,136 @@ pub struct GameMainConfigSnapshot { pub default_connection_group: Option, } +/// Versioned artifact status for official launcher bootstrap data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OfficialLauncherBootstrapArtifactStatus { + /// The artifact belongs to a fully published official resource release. + Published, + /// The launcher/server-info chain advanced, but required game resources are + /// not yet readable from the official client-patch CDN. + WaitingForOfficialResources, +} + +/// Resource-release context attached to one launcher bootstrap artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherBootstrapContext { + /// Selected connection group. + pub connection_group_name: String, + /// Selected app version. + pub app_version: String, + /// Bundle version from server-info, when present. + pub bundle_version: Option, + /// Selected official Addressables root URL. + pub addressables_root: String, +} + +/// Official launcher CDN roots observed for this bootstrap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherCdnConfigSnapshot { + /// Primary official launcher package CDN root. + pub primary_cdn: String, + /// Backup official launcher package CDN root. + pub back_up_cdn: String, +} + +/// One file entry from the official launcher remote manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherManifestFileSnapshot { + /// Manifest path relative to the game root, preserving official spelling. + pub path: String, + /// Official size field as received. + pub size: String, + /// Parsed size, when the official size field is valid decimal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parsed_size: Option, + /// Official launcher manifest hash field. + pub hash: String, + /// Official per-file integrity hash. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vc: Option, +} + +/// Remote launcher manifest captured for audit and downstream bootstrap use. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherRemoteManifestSnapshot { + /// Remote manifest URL returned by the official launcher API. + pub url: String, + /// Remote manifest source path. + pub source: Option, + /// Number of files declared by the remote manifest. + pub file_count: usize, + /// Stable BLAKE3 digest over the ordered manifest file list. + pub files_blake3: String, + /// Ordered file entries from the remote launcher manifest. + pub files: Vec, +} + +/// Kind of source selected for `GameMainConfig` extraction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OfficialLauncherGameMainConfigSourceKind { + /// Older launcher manifests point to a single game ZIP archive. + Archive, + /// Current launcher manifests expose a directory plus per-file entries. + ManifestFile, +} + +/// Exact official launcher artifact used to obtain `GameMainConfig`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherGameMainConfigSourceSnapshot { + /// Source kind. + pub kind: OfficialLauncherGameMainConfigSourceKind, + /// Official URL fetched for this source. + pub url: String, + /// Relative path under the official launcher package CDN root. + pub relative_path: String, + /// Original manifest file path when the source is a manifest file entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Declared file size from the manifest, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub declared_size: Option, + /// Official launcher manifest `hash` field, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub official_hash: Option, + /// Official launcher manifest per-file `vc`, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vc: Option, +} + +/// Launcher bootstrap data resolved during one official update run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherBootstrapData { + /// Launcher metadata summary also stored in `official-sync-snapshot.json`. + pub launcher_metadata: LauncherMetadataSnapshot, + /// Decrypted `GameMainConfig` summary. + pub game_main_config: GameMainConfigSnapshot, + /// Official launcher CDN roots. + pub cdn_config: OfficialLauncherCdnConfigSnapshot, + /// Remote launcher manifest file list. + pub remote_manifest: OfficialLauncherRemoteManifestSnapshot, + /// Exact source selected for `GameMainConfig`. + pub selected_game_main_config_source: OfficialLauncherGameMainConfigSourceSnapshot, +} + +/// Versioned official launcher bootstrap artifact written next to a release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialLauncherBootstrapArtifact { + /// Artifact schema version. + #[serde(default = "default_launcher_bootstrap_artifact_version")] + pub artifact_version: u32, + /// Whether this artifact belongs to a published release or a pending + /// maintenance-period observation. + pub status: OfficialLauncherBootstrapArtifactStatus, + /// Write time for this artifact. + pub generated_unix_seconds: u64, + /// Official resource context this launcher data resolved to. + pub context: OfficialLauncherBootstrapContext, + /// Captured launcher bootstrap data. + pub launcher_bootstrap: OfficialLauncherBootstrapData, +} + /// Cached GameMainConfig summary keyed by launcher metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialBootstrapCache { @@ -416,6 +636,8 @@ pub struct ResolvedBootstrap { pub launcher_metadata: LauncherMetadataSnapshot, /// GameMainConfig summary. pub game_main_config: GameMainConfigSnapshot, + /// Versionable launcher bootstrap data. + pub launcher_bootstrap: OfficialLauncherBootstrapData, /// Whether GameMainConfig came from the local cache. pub cache_hit: bool, } @@ -533,6 +755,13 @@ pub struct OfficialUpdateReport { pub repair: bool, /// Whether a download was required. pub should_download: bool, + /// Whether launcher/server-info advertised a newer resource root whose + /// required client-patch endpoints are not yet readable. + #[serde(default)] + pub waiting_for_official_resources: bool, + /// Unavailable official seed/marker endpoints observed before staging. + #[serde(default)] + pub unavailable_endpoints: Vec, /// Whether this was the first observed snapshot. pub is_initial: bool, /// Changed endpoint URLs from legacy diff. @@ -575,12 +804,36 @@ pub struct OfficialUpdateReport { pub verification_summary: OfficialVerificationSummary, /// Download manifest path. pub download_manifest: PathBuf, + /// Resource-change-set path written after a verified release publish. + pub resource_change_set_path: Option, + /// Crowdin translation handoff path reserved for added/modified resources. + pub crowdin_handoff_path: Option, + /// Summary of resource changes between the previous complete release and + /// the current release. + pub resource_change_summary: Option, /// Parse-cache path written or refreshed after successful verification. pub parse_cache_path: Option, /// Post-sync parse-cache summary. pub parse_summary: Option, + /// Incremental TextUnit task queue path derived from change set + parse cache. + pub textunit_task_queue_path: Option, + /// Crowdin offline TextUnit queue path derived from queued TextUnit tasks. + pub crowdin_textunit_queue_path: Option, + /// Incremental TextUnit task queue summary. + pub textunit_task_summary: Option, /// Snapshot path written after success. pub snapshot_written: Option, + /// Launcher bootstrap artifact written for this run. + pub launcher_bootstrap_artifact_path: Option, + /// Whether the optional CAS + ResourceRepository import was enabled. + pub repository_import_enabled: bool, + /// CAS root used by the optional importer. + pub repository_import_cas_root: Option, + /// SQLite ResourceRepository path used by the optional importer. + pub repository_import_path: Option, + /// Optional import summary after a verified official release was published + /// or confirmed up-to-date. + pub repository_import_summary: Option, } /// Human-readable progress emitted while one official update run is executing. @@ -1061,12 +1314,13 @@ impl OfficialUpdateService { marker_endpoint_count(¤t_snapshot) ), )); - let endpoint_markers = collect_endpoint_markers( + let marker_collection = collect_endpoint_markers( &fetcher, ¤t_snapshot, &mut progress, &mut should_cancel, )?; + let endpoint_markers = marker_collection.markers; let current_update_snapshot = OfficialUpdateSnapshot::new( current_snapshot.clone(), endpoint_markers, @@ -1086,6 +1340,57 @@ impl OfficialUpdateService { let extended_delta = diff_extended_snapshot(¤t_update_snapshot, previous_snapshot.as_ref()); let changed_urls = changed_endpoint_urls(&sync_plan.delta); + if !marker_collection.unavailable_endpoints.is_empty() { + progress(OfficialUpdateProgress::new( + "upstream", + format!( + "官方启动器/server-info 已更新,但 {} 个远端标记尚未开放;保留当前资源并等待下次检查", + marker_collection.unavailable_endpoints.len() + ), + )); + let launcher_bootstrap_artifact_path = if !config.dry_run && bootstrap.is_some() { + progress(OfficialUpdateProgress::new( + "launcher-bootstrap", + format!( + "写入待开放官方启动器 bootstrap {}", + config + .output_root + .join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE) + .display() + ), + )); + write_launcher_bootstrap_artifact_for_snapshot( + config, + &config.output_root, + ¤t_update_snapshot, + bootstrap.as_ref(), + OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources, + OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE, + )? + } else { + None + }; + let mut report = waiting_for_official_resources_report( + config, + platforms, + &publish_layout, + &active_resource_root, + &snapshot_path, + &version_state_path, + ¤t_snapshot, + ¤t_update_snapshot, + previous_snapshot.is_some(), + format!("{:?}", sync_plan.decision), + sync_plan.delta.is_initial, + changed_urls, + extended_delta, + bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit), + Some(bootstrap_cache_path.clone()), + marker_collection.unavailable_endpoints, + ); + report.launcher_bootstrap_artifact_path = launcher_bootstrap_artifact_path; + return Ok(report); + } let remote_should_download = config.force || sync_plan.should_download() || extended_delta.has_changes(); progress(OfficialUpdateProgress::new( @@ -1099,7 +1404,7 @@ impl OfficialUpdateService { "plan", "根据最新种子目录构建官方拉取计划", )); - let pull_plan = build_pull_plan( + let pull_plan = match build_pull_plan( &server_info, &connection_group, &app_version, @@ -1107,7 +1412,64 @@ impl OfficialUpdateService { &fetcher, &mut progress, &mut should_cancel, - )?; + ) { + Ok(plan) => plan, + Err(error) => { + if let Some(unavailable) = error.downcast_ref::() { + progress(OfficialUpdateProgress::new( + "upstream", + format!( + "官方启动器/server-info 已更新,但必需资源尚未开放;保留当前资源并等待下次检查:{}", + unavailable.endpoint.url + ), + )); + let launcher_bootstrap_artifact_path = if !config.dry_run && bootstrap.is_some() + { + progress(OfficialUpdateProgress::new( + "launcher-bootstrap", + format!( + "写入待开放官方启动器 bootstrap {}", + config + .output_root + .join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE) + .display() + ), + )); + write_launcher_bootstrap_artifact_for_snapshot( + config, + &config.output_root, + ¤t_update_snapshot, + bootstrap.as_ref(), + OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources, + OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE, + )? + } else { + None + }; + let mut report = waiting_for_official_resources_report( + config, + platforms, + &publish_layout, + &active_resource_root, + &snapshot_path, + &version_state_path, + ¤t_snapshot, + ¤t_update_snapshot, + previous_snapshot.is_some(), + format!("{:?}", sync_plan.decision), + sync_plan.delta.is_initial, + changed_urls, + extended_delta, + bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit), + Some(bootstrap_cache_path.clone()), + vec![unavailable.endpoint.clone()], + ); + report.launcher_bootstrap_artifact_path = launcher_bootstrap_artifact_path; + return Ok(report); + } + return Err(error); + } + }; let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); progress(OfficialUpdateProgress::new( "plan", @@ -1199,6 +1561,9 @@ impl OfficialUpdateService { )); } check_shutdown_requested(&mut should_cancel)?; + let active_release_id = version_id_from_path(&active_resource_root) + .unwrap_or_else(|| fallback_version_id(¤t_update_snapshot)); + let localized_info = localized_release_info_for(config, Some(active_release_id.as_str())); let mut report = OfficialUpdateReport { update_status: if should_download { OfficialUpdateStatus::WouldDownload @@ -1212,9 +1577,9 @@ impl OfficialUpdateService { 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, + localized_release_status: localized_info.status, + localized_current_path: localized_info.current_path, + localized_published_version_path: localized_info.published_version_path, active_resource_root: active_resource_root.clone(), current_path: publish_layout.current_path.clone(), version_state_path: version_state_path.clone(), @@ -1227,6 +1592,8 @@ impl OfficialUpdateService { audit_local: config.audit_local, repair: config.repair, should_download, + waiting_for_official_resources: false, + unavailable_endpoints: Vec::new(), is_initial: sync_plan.delta.is_initial, changed_endpoint_urls: changed_urls, extended_delta, @@ -1254,9 +1621,24 @@ impl OfficialUpdateService { local_zip_structure_verified_count, ), download_manifest: fetcher.download_manifest_path(), + resource_change_set_path: None, + crowdin_handoff_path: None, + resource_change_summary: None, parse_cache_path: None, parse_summary: None, + textunit_task_queue_path: None, + crowdin_textunit_queue_path: None, + textunit_task_summary: None, snapshot_written: None, + launcher_bootstrap_artifact_path: None, + repository_import_enabled: config.import_repository, + repository_import_cas_root: config + .import_repository + .then(|| config.effective_import_cas_root()), + repository_import_path: config + .import_repository + .then(|| config.effective_import_resource_repository_path()), + repository_import_summary: None, }; if !should_download { @@ -1284,13 +1666,52 @@ impl OfficialUpdateService { &active_resource_root, &snapshot_path, )?; - run_post_sync_parse_cache( + let active_launcher_bootstrap_path = + active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE); + if bootstrap.is_some() + && path_exists_no_follow(&active_launcher_bootstrap_path) + .map_err(anyhow::Error::msg)? + { + report.launcher_bootstrap_artifact_path = Some(active_launcher_bootstrap_path); + } else if bootstrap.is_some() { + progress(OfficialUpdateProgress::new( + "launcher-bootstrap", + format!( + "写入当前官方启动器 bootstrap {}", + active_launcher_bootstrap_path.display() + ), + )); + report.launcher_bootstrap_artifact_path = + write_launcher_bootstrap_artifact_for_snapshot( + config, + &active_resource_root, + ¤t_update_snapshot, + bootstrap.as_ref(), + OfficialLauncherBootstrapArtifactStatus::Published, + OFFICIAL_LAUNCHER_BOOTSTRAP_FILE, + )?; + } + run_post_sync_parse_cache_if_needed( config, &active_resource_root, &mut report, &mut progress, &mut should_cancel, )?; + run_post_sync_textunit_queue_if_needed( + &active_resource_root, + &mut report, + &mut progress, + &mut should_cancel, + )?; + run_post_sync_repository_import( + config, + &active_resource_root, + Some(active_release_id.as_str()), + &mut report, + &mut progress, + &mut should_cancel, + )?; } progress(OfficialUpdateProgress::new( "finish", @@ -1443,6 +1864,28 @@ impl OfficialUpdateService { if config.snapshot_path.is_none() { write_snapshot(&staging_snapshot_path, ¤t_update_snapshot)?; } + let staging_launcher_bootstrap_artifact_path = if bootstrap.is_some() { + progress(OfficialUpdateProgress::new( + "launcher-bootstrap", + format!( + "写入官方启动器 bootstrap {}", + publish_plan + .staging_path + .join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE) + .display() + ), + )); + write_launcher_bootstrap_artifact_for_snapshot( + config, + &publish_plan.staging_path, + ¤t_update_snapshot, + bootstrap.as_ref(), + OfficialLauncherBootstrapArtifactStatus::Published, + OFFICIAL_LAUNCHER_BOOTSTRAP_FILE, + )? + } else { + None + }; progress(OfficialUpdateProgress::new( "publish", @@ -1458,6 +1901,7 @@ impl OfficialUpdateService { // 事务,使后续 snapshot / version-state 写入失败不会把这个已发布版本经 // VersionStateGuard::Drop 误记为 failed,而是降级为可下轮重试的警告。 let completed_record = version_state_guard.record()?.clone(); + let completed_release_id = completed_record.id.clone(); version_state_guard.commit(); let final_snapshot_path = snapshot_path_for(config, &published_version_path); @@ -1492,6 +1936,9 @@ impl OfficialUpdateService { report.local_manifest_repair_needed_count = final_audit.repair_needed_count(); report.verification_summary = final_verification_summary; report.snapshot_written = snapshot_written.then(|| final_snapshot_path.clone()); + report.launcher_bootstrap_artifact_path = staging_launcher_bootstrap_artifact_path + .map(|_| published_version_path.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE)); + apply_localized_release_info(&mut report, config, Some(completed_release_id.as_str())); if let Err(error) = complete_version_state( &version_state_path, completed_record, @@ -1503,6 +1950,15 @@ impl OfficialUpdateService { format!("资源已发布,但写入版本状态失败(下轮可重试):{error}"), )); } + run_post_sync_resource_handoff( + Some(&active_resource_root), + &published_version_path, + completed_release_id.as_str(), + version_id_from_path(&active_resource_root), + &mut report, + &mut progress, + &mut should_cancel, + )?; run_post_sync_parse_cache( config, &published_version_path, @@ -1510,6 +1966,20 @@ impl OfficialUpdateService { &mut progress, &mut should_cancel, )?; + run_post_sync_textunit_queue_if_needed( + &published_version_path, + &mut report, + &mut progress, + &mut should_cancel, + )?; + run_post_sync_repository_import( + config, + &published_version_path, + Some(completed_release_id.as_str()), + &mut report, + &mut progress, + &mut should_cancel, + )?; // 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。 match read_version_state(&version_state_path) { @@ -1543,6 +2013,86 @@ impl OfficialUpdateService { } } +fn run_post_sync_resource_handoff( + previous_resource_root: Option<&Path>, + current_resource_root: &Path, + official_release_id: &str, + previous_release_id: Option, + report: &mut OfficialUpdateReport, + progress: &mut dyn FnMut(OfficialUpdateProgress), + should_cancel: &mut dyn FnMut() -> bool, +) -> anyhow::Result<()> { + check_shutdown_requested(should_cancel)?; + progress(OfficialUpdateProgress::new( + "changes", + format!( + "生成官方资源变更集:previous={} current={}", + previous_resource_root + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()), + current_resource_root.display() + ), + )); + let handoff_report = write_official_resource_change_handoff( + previous_resource_root, + current_resource_root, + official_release_id, + previous_release_id, + ) + .map_err(anyhow::Error::msg)?; + apply_resource_change_handoff_report(report, handoff_report); + if let Some(summary) = report.resource_change_summary.as_ref() { + progress(OfficialUpdateProgress::new( + "changes", + format!( + "资源变更集完成:新增={} 变更={} 删除={} 解析候选={} Crowdin候选={}", + summary.added_count, + summary.modified_count, + summary.removed_count, + summary.parse_candidate_count, + summary.translation_candidate_count + ), + )); + } + Ok(()) +} + +fn apply_resource_change_handoff_report( + report: &mut OfficialUpdateReport, + handoff_report: OfficialResourceChangeHandoffReport, +) { + report.resource_change_set_path = Some(handoff_report.change_set_path); + report.crowdin_handoff_path = Some(handoff_report.crowdin_handoff_path); + report.resource_change_summary = Some(handoff_report.summary); +} + +fn run_post_sync_parse_cache_if_needed( + 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)?; + if let Some(cache) = read_parse_cache_at(resource_root).map_err(anyhow::Error::msg)? { + let cache_path = resource_root.join(crate::OFFICIAL_PARSE_CACHE_FILE); + progress(OfficialUpdateProgress::new( + "parse", + format!( + "官方资源未变更,复用解析缓存 {}:条目={} TextUnit={}", + cache_path.display(), + cache.summary.cache_entry_count, + cache.summary.text_unit_count + ), + )); + report.parse_cache_path = Some(cache_path); + report.parse_summary = Some(cache.summary); + return Ok(()); + } + + run_post_sync_parse_cache(config, resource_root, report, progress, should_cancel) +} + fn run_post_sync_parse_cache( config: &OfficialUpdateConfig, resource_root: &Path, @@ -1561,13 +2111,15 @@ fn run_post_sync_parse_cache( progress(OfficialUpdateProgress::new( "parse", format!( - "解析缓存完成:条目={} 已解析={} 复用={} 不支持={} 失败={} TextAsset={}", + "解析缓存完成:条目={} 已解析={} 复用={} 不支持={} 失败={} TextAsset={} TextUnit={} 诊断={}", 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 + parse_report.summary.text_asset_count, + parse_report.summary.text_unit_count, + parse_report.summary.text_unit_error_count ), )); report.parse_cache_path = Some(parse_report.cache_path); @@ -1583,6 +2135,179 @@ fn run_post_sync_parse_cache( Ok(()) } +fn run_post_sync_textunit_queue_if_needed( + resource_root: &Path, + report: &mut OfficialUpdateReport, + progress: &mut dyn FnMut(OfficialUpdateProgress), + should_cancel: &mut dyn FnMut() -> bool, +) -> anyhow::Result<()> { + check_shutdown_requested(should_cancel)?; + if let Some(queue) = read_textunit_task_queue_at(resource_root).map_err(anyhow::Error::msg)? { + let task_queue_path = resource_root.join(crate::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + let crowdin_queue_path = resource_root.join(crate::CROWDIN_TEXTUNIT_QUEUE_FILE); + if is_textunit_task_queue_current(resource_root, &queue).map_err(anyhow::Error::msg)? + && is_crowdin_textunit_queue_current(resource_root, &queue) + .map_err(anyhow::Error::msg)? + { + progress(OfficialUpdateProgress::new( + "textunit", + format!( + "复用增量 TextUnit 队列 {}:任务={} TextUnit={}", + task_queue_path.display(), + queue.summary.queued_task_count, + queue.summary.text_unit_count + ), + )); + report.textunit_task_queue_path = Some(task_queue_path); + report.crowdin_textunit_queue_path = Some(crowdin_queue_path); + report.textunit_task_summary = Some(queue.summary); + return Ok(()); + } + progress(OfficialUpdateProgress::new( + "textunit", + format!( + "TextUnit 队列输入已变化或 Crowdin 队列缺失,重新生成 {}", + resource_root.display() + ), + )); + } + + run_post_sync_textunit_queue(resource_root, report, progress, should_cancel) +} + +fn run_post_sync_textunit_queue( + 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( + "textunit", + format!( + "生成增量 TextUnit / Crowdin 离线队列 {}", + resource_root.display() + ), + )); + match write_official_textunit_queues(resource_root) { + Ok(queue_report) => { + progress(OfficialUpdateProgress::new( + "textunit", + format!( + "增量 TextUnit 队列完成:资源候选={} 任务={} TextUnit={} 跳过无解析={} 无文本={} 解析失败={} 不支持={}", + queue_report.summary.resource_candidate_count, + queue_report.summary.queued_task_count, + queue_report.summary.text_unit_count, + queue_report.summary.skipped_no_parse_entry_count, + queue_report.summary.skipped_no_text_unit_count, + queue_report.summary.skipped_parse_failed_count, + queue_report.summary.skipped_unsupported_count + ), + )); + apply_textunit_queue_report(report, queue_report); + } + Err(error) => { + progress(OfficialUpdateProgress::new( + "textunit", + format!("增量 TextUnit 队列生成失败(不影响已校验官方资源):{error}"), + )); + } + } + Ok(()) +} + +fn apply_textunit_queue_report( + report: &mut OfficialUpdateReport, + queue_report: OfficialTextUnitQueueReport, +) { + report.textunit_task_queue_path = Some(queue_report.textunit_task_queue_path); + report.crowdin_textunit_queue_path = Some(queue_report.crowdin_textunit_queue_path); + report.textunit_task_summary = Some(queue_report.summary); +} + +fn run_post_sync_repository_import( + config: &OfficialUpdateConfig, + resource_root: &Path, + official_release_id: Option<&str>, + report: &mut OfficialUpdateReport, + progress: &mut dyn FnMut(OfficialUpdateProgress), + should_cancel: &mut dyn FnMut() -> bool, +) -> anyhow::Result<()> { + check_shutdown_requested(should_cancel)?; + if !config.import_repository { + progress(OfficialUpdateProgress::new( + "repository", + "CAS + ResourceRepository 导入未启用", + )); + return Ok(()); + } + validate_repository_import_paths(config).map_err(anyhow::Error::msg)?; + let cas_root = config.effective_import_cas_root(); + let repository_path = config.effective_import_resource_repository_path(); + report.repository_import_enabled = true; + report.repository_import_cas_root = Some(cas_root.clone()); + report.repository_import_path = Some(repository_path.clone()); + + progress(OfficialUpdateProgress::new( + "repository", + format!( + "导入官方 release 到 CAS={} ResourceRepository={}", + cas_root.display(), + repository_path.display() + ), + )); + let import_report = import_official_release_to_repository( + resource_root, + official_release_id, + &cas_root, + &repository_path, + )?; + progress(OfficialUpdateProgress::new( + "repository", + format!( + "Repository 导入完成:manifest={} imported={} unchanged={} metadata_updated={} AssetBundle={} TextAsset={} Table={} Media={}", + import_report.manifest_entry_count, + import_report.imported_count, + import_report.unchanged_count, + import_report.metadata_updated_count, + import_report.asset_bundle_count, + import_report.text_asset_count, + import_report.table_count, + import_report.media_count + ), + )); + report.repository_import_summary = Some(import_report); + Ok(()) +} + +fn import_official_release_to_repository( + resource_root: &Path, + official_release_id: Option<&str>, + cas_root: &Path, + repository_path: &Path, +) -> anyhow::Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let cas = FileSystemCasRepository::new(cas_root); + cas.init() + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let resources = SqliteResourceRepository::new(repository_path) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let mut import_config = OfficialReleaseImportConfig::new(resource_root); + if let Some(release_id) = official_release_id { + import_config = import_config.with_official_release_id(release_id); + } + OfficialReleaseImportService::new(&cas, &resources) + .import_release(&import_config) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + }) +} + fn build_pull_plan( server_info: &YostarJpServerInfo, connection_group: &str, @@ -1634,13 +2359,104 @@ fn load_server_info( } } +#[allow(clippy::too_many_arguments)] +fn waiting_for_official_resources_report( + config: &OfficialUpdateConfig, + platforms: &[PatchPlatform], + publish_layout: &OfficialPublishLayout, + active_resource_root: &Path, + snapshot_path: &Path, + version_state_path: &Path, + base_snapshot: &YostarJpSyncSnapshot, + update_snapshot: &OfficialUpdateSnapshot, + previous_snapshot_present: bool, + decision: String, + is_initial: bool, + changed_endpoint_urls: Vec, + extended_delta: ExtendedSnapshotDelta, + bootstrap_cache_hit: Option, + bootstrap_cache_path: Option, + unavailable_endpoints: Vec, +) -> OfficialUpdateReport { + let active_release_id = version_id_from_path(active_resource_root) + .unwrap_or_else(|| fallback_version_id(update_snapshot)); + let localized_info = localized_release_info_for(config, Some(active_release_id.as_str())); + OfficialUpdateReport { + update_status: OfficialUpdateStatus::WaitingForOfficialResources, + connection_group: base_snapshot.connection_group_name.clone(), + app_version: base_snapshot.app_version.clone(), + bundle_version: base_snapshot.bundle_version.clone(), + addressables_root: base_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: localized_info.status, + localized_current_path: localized_info.current_path, + localized_published_version_path: localized_info.published_version_path, + active_resource_root: active_resource_root.to_path_buf(), + current_path: publish_layout.current_path.clone(), + version_state_path: version_state_path.to_path_buf(), + published_version_path: None, + staging_path: None, + snapshot_path: snapshot_path.to_path_buf(), + previous_snapshot_present, + decision, + force: config.force, + audit_local: config.audit_local, + repair: config.repair, + should_download: false, + waiting_for_official_resources: true, + unavailable_endpoints, + is_initial, + changed_endpoint_urls, + extended_delta, + addressables_marker_checked_count: update_snapshot.addressables_marker_checked_count(), + unverified_marker_count: update_snapshot.unverified_marker_count(), + bootstrap_cache_hit, + bootstrap_cache_path, + local_manifest_verified_count: 0, + local_manifest_repair_needed_count: 0, + dry_run: config.dry_run, + download_url_count: None, + download_urls: Vec::new(), + resource_count: None, + downloaded_count: 0, + resumed_count: 0, + skipped_count: 0, + final_bytes: 0, + transferred_bytes: 0, + official_seed_hash_verified_count: 0, + verification_summary: OfficialVerificationSummary::new(0, 0, 0, 0), + download_manifest: active_resource_root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), + resource_change_set_path: None, + crowdin_handoff_path: None, + resource_change_summary: None, + parse_cache_path: None, + parse_summary: None, + textunit_task_queue_path: None, + crowdin_textunit_queue_path: None, + textunit_task_summary: None, + snapshot_written: None, + launcher_bootstrap_artifact_path: None, + repository_import_enabled: config.import_repository, + repository_import_cas_root: config + .import_repository + .then(|| config.effective_import_cas_root()), + repository_import_path: config + .import_repository + .then(|| config.effective_import_resource_repository_path()), + repository_import_summary: None, + } +} + fn collect_endpoint_markers( fetcher: &OfficialResourcePullService, snapshot: &YostarJpSyncSnapshot, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, -) -> anyhow::Result> { +) -> anyhow::Result { let mut markers = Vec::new(); + let mut unavailable_endpoints = Vec::new(); for endpoint in &snapshot.endpoints { let Some(role) = marker_role(endpoint.kind) else { @@ -1656,9 +2472,29 @@ fn collect_endpoint_markers( endpoint.url ), )); - let bytes = fetcher - .fetch_bytes(&endpoint.url) - .map_err(anyhow::Error::new)?; + let bytes = match fetcher.fetch_bytes(&endpoint.url) { + Ok(bytes) => bytes, + Err(error) if is_official_resource_not_ready(&error) => { + let unavailable = OfficialUnavailableEndpoint::from_fetch_error(endpoint, &error); + progress(OfficialUpdateProgress::new( + "marker", + format!( + "{} 标记{} 当前不可用({} HTTP={}),等待官方资源端开放:{}", + endpoint_kind_label(endpoint.kind), + platform_suffix(endpoint.platform), + unavailable.error_kind, + unavailable + .http_status + .map(|status| status.to_string()) + .unwrap_or_else(|| "none".to_string()), + endpoint.url + ), + )); + unavailable_endpoints.push(unavailable); + continue; + } + Err(error) => return Err(anyhow::Error::new(error)), + }; let value = String::from_utf8_lossy(&bytes).trim().to_string(); markers.push(OfficialEndpointMarkerSnapshot { kind: endpoint.kind, @@ -1670,7 +2506,77 @@ fn collect_endpoint_markers( } check_shutdown_requested(should_cancel)?; - Ok(markers) + Ok(EndpointMarkerCollection { + markers, + unavailable_endpoints, + }) +} + +#[derive(Debug, Clone, Default)] +struct EndpointMarkerCollection { + markers: Vec, + unavailable_endpoints: Vec, +} + +#[derive(Debug, Clone)] +struct OfficialResourceUnavailable { + endpoint: OfficialUnavailableEndpoint, +} + +impl OfficialResourceUnavailable { + fn new(endpoint: &YostarJpResourceEndpoint, error: &DownloadError) -> Self { + Self { + endpoint: OfficialUnavailableEndpoint::from_fetch_error(endpoint, error), + } + } +} + +impl std::fmt::Display for OfficialResourceUnavailable { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "官方资源端尚未开放:kind={}{} url={} error_kind={} http={} error={}", + endpoint_kind_label(self.endpoint.kind), + platform_suffix(self.endpoint.platform), + self.endpoint.url, + self.endpoint.error_kind, + self.endpoint + .http_status + .map(|status| status.to_string()) + .unwrap_or_else(|| "none".to_string()), + self.endpoint.error + ) + } +} + +impl std::error::Error for OfficialResourceUnavailable {} + +fn is_official_resource_not_ready(error: &DownloadError) -> bool { + matches!( + error.code(), + ErrorCode::HTTP_FORBIDDEN | ErrorCode::HTTP_NOT_FOUND | ErrorCode::HTTP_CLIENT_ERROR + ) +} + +fn http_status_from_error_message(message: &str) -> Option { + if let Some(rest) = message.split("http_status=").nth(1) { + return parse_leading_u16(rest); + } + if let Some(rest) = message.split("returned error: ").nth(1) { + return parse_leading_u16(rest); + } + None +} + +fn parse_leading_u16(value: &str) -> Option { + let digits = value + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if digits.is_empty() { + return None; + } + digits.parse().ok() } fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> { @@ -1824,6 +2730,80 @@ fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static } } +#[derive(Debug, Clone)] +struct LocalizedReleaseInfo { + status: LocalizedReleaseStatus, + current_path: PathBuf, + published_version_path: Option, +} + +fn apply_localized_release_info( + report: &mut OfficialUpdateReport, + config: &OfficialUpdateConfig, + official_release_id: Option<&str>, +) { + let info = localized_release_info_for(config, official_release_id); + report.localized_release_status = info.status; + report.localized_current_path = info.current_path; + report.localized_published_version_path = info.published_version_path; +} + +fn localized_release_info_for( + config: &OfficialUpdateConfig, + official_release_id: Option<&str>, +) -> LocalizedReleaseInfo { + let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); + let state = match read_localized_version_state(&config.localized_output_root) { + Ok(Some(state)) => state, + Ok(None) | Err(_) => return not_localized_release_info(current_path), + }; + if state.status != LocalizedReleaseStatus::Localized.as_str() { + return not_localized_release_info(current_path); + } + if official_release_id.is_some_and(|id| state.official_release_id != id) { + return not_localized_release_info(current_path); + } + let Some(release_id) = state.current_release_id.as_deref() else { + return not_localized_release_info(current_path); + }; + let version_path = config + .localized_output_root + .join(LOCALIZED_VERSIONS_DIR) + .join(release_id); + if !version_path.is_dir() || !localized_current_points_to(¤t_path, &version_path) { + return not_localized_release_info(current_path); + } + + LocalizedReleaseInfo { + status: LocalizedReleaseStatus::Localized, + current_path, + published_version_path: Some(version_path), + } +} + +fn not_localized_release_info(current_path: PathBuf) -> LocalizedReleaseInfo { + LocalizedReleaseInfo { + status: LocalizedReleaseStatus::NotLocalized, + current_path, + published_version_path: None, + } +} + +fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool { + let Ok(target) = fs::read_link(current_path) else { + return false; + }; + let resolved = if target.is_absolute() { + target + } else { + current_path + .parent() + .map(|parent| parent.join(&target)) + .unwrap_or(target) + }; + resolved == version_path +} + /// Computes the extended snapshot delta. pub fn diff_extended_snapshot( current: &OfficialUpdateSnapshot, @@ -1851,6 +2831,9 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> { 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 config.import_repository { + validate_repository_import_paths(config)?; + } 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, "官方更新快照")?; @@ -1869,6 +2852,44 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> { Ok(()) } +fn validate_repository_import_paths(config: &OfficialUpdateConfig) -> Result<(), String> { + let cas_root = config.effective_import_cas_root(); + validate_output_root(&cas_root)?; + ensure_safe_directory_path(&cas_root, "官方资源 CAS 导入目录")?; + let official = lexical_absolute(&config.output_root)?; + let localized = lexical_absolute(&config.localized_output_root)?; + let cas = lexical_absolute(&cas_root)?; + if cas == official { + return Err(format!( + "官方资源 CAS 导入目录不能直接等于官方资源发布根:{}", + cas.display() + )); + } + if cas == localized || cas.starts_with(&localized) { + return Err(format!( + "官方资源 CAS 导入目录不能位于汉化输出目录内:CAS={} 汉化={}", + cas.display(), + localized.display() + )); + } + + let repository_path = config.effective_import_resource_repository_path(); + let repository_parent = repository_path + .parent() + .ok_or_else(|| format!("资源索引数据库缺少父目录:{}", repository_path.display()))?; + ensure_safe_directory_path(repository_parent, "资源索引数据库目录")?; + ensure_safe_file_target(repository_parent, &repository_path, "资源索引数据库")?; + let repository = lexical_absolute(&repository_path)?; + if repository.starts_with(&localized) { + return Err(format!( + "资源索引数据库不能位于汉化输出目录内:数据库={} 汉化={}", + repository.display(), + localized.display() + )); + } + Ok(()) +} + fn validate_separate_output_roots( output_root: &Path, localized_output_root: &Path, @@ -2107,6 +3128,37 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow: Ok(()) } +/// Reads a versioned official launcher bootstrap artifact from disk. +pub fn read_launcher_bootstrap_artifact( + path: &Path, +) -> anyhow::Result> { + let Some(data) = + read_file_no_symlink(path, "官方启动器 bootstrap 产物").map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let artifact: OfficialLauncherBootstrapArtifact = serde_json::from_slice(&data)?; + if artifact.artifact_version != OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION { + return Err(anyhow::anyhow!( + "不支持的官方启动器 bootstrap schema:{},当前版本={}", + artifact.artifact_version, + OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION + )); + } + Ok(Some(artifact)) +} + +/// Writes a versioned official launcher bootstrap artifact atomically. +pub fn write_launcher_bootstrap_artifact( + path: &Path, + artifact: &OfficialLauncherBootstrapArtifact, +) -> anyhow::Result<()> { + let data = serde_json::to_vec_pretty(artifact)?; + write_file_atomic(path, &data, STATE_FILE_MODE, "官方启动器 bootstrap 产物") + .map_err(anyhow::Error::msg)?; + Ok(()) +} + /// Reads the persistent official version state. pub fn read_version_state(path: &Path) -> anyhow::Result> { let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)? @@ -2169,6 +3221,10 @@ fn default_bootstrap_cache_version() -> u32 { OFFICIAL_BOOTSTRAP_CACHE_VERSION } +fn default_launcher_bootstrap_artifact_version() -> u32 { + OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION +} + fn default_version_state_version() -> u32 { OFFICIAL_VERSION_STATE_VERSION } @@ -2579,6 +3635,7 @@ fn launcher_metadata_from_parts( manifest_url: manifest_url.url.clone(), manifest_source: manifest.source.clone().filter(|value| !value.is_empty()), manifest_file_count: manifest.files.len(), + manifest_files_blake3: Some(launcher_manifest_files_blake3(&manifest.files)), } } @@ -2589,6 +3646,133 @@ fn game_main_config_snapshot(config: &YostarJpGameMainConfig) -> GameMainConfigS } } +fn launcher_manifest_files_blake3(files: &[YostarJpLauncherManifestFile]) -> String { + let mut hasher = blake3::Hasher::new(); + for file in files { + hasher.update(file.path.as_bytes()); + hasher.update(&[0]); + hasher.update(file.size.as_bytes()); + hasher.update(&[0]); + hasher.update(file.hash.as_bytes()); + hasher.update(&[0]); + if let Some(vc) = file.vc.as_deref() { + hasher.update(b"vc"); + hasher.update(vc.as_bytes()); + } + hasher.update(&[0xff]); + } + hasher.finalize().to_hex().to_string() +} + +fn launcher_manifest_file_snapshot( + file: &YostarJpLauncherManifestFile, +) -> OfficialLauncherManifestFileSnapshot { + OfficialLauncherManifestFileSnapshot { + path: file.path.clone(), + size: file.size.clone(), + parsed_size: file.size.parse::().ok(), + hash: file.hash.clone(), + vc: file.vc.clone(), + } +} + +fn launcher_game_main_config_source_snapshot( + source: &OfficialGameMainConfigSelectedSource, +) -> OfficialLauncherGameMainConfigSourceSnapshot { + OfficialLauncherGameMainConfigSourceSnapshot { + kind: match source.kind { + OfficialGameMainConfigSourceKind::Archive => { + OfficialLauncherGameMainConfigSourceKind::Archive + } + OfficialGameMainConfigSourceKind::ManifestFile => { + OfficialLauncherGameMainConfigSourceKind::ManifestFile + } + }, + url: source.url.clone(), + relative_path: source.relative_path.clone(), + manifest_path: source.manifest_path.clone(), + declared_size: source.declared_size, + official_hash: source.official_hash.clone(), + vc: source.vc.clone(), + } +} + +fn launcher_bootstrap_data_from_parts( + launcher_metadata: LauncherMetadataSnapshot, + game_main_config: GameMainConfigSnapshot, + cdn_config: &YostarJpLauncherCdnConfig, + manifest_url: &str, + manifest: &YostarJpLauncherRemoteManifest, + selected_source: &OfficialGameMainConfigSelectedSource, +) -> OfficialLauncherBootstrapData { + OfficialLauncherBootstrapData { + launcher_metadata, + game_main_config, + cdn_config: OfficialLauncherCdnConfigSnapshot { + primary_cdn: cdn_config.primary_cdn.clone(), + back_up_cdn: cdn_config.back_up_cdn.clone(), + }, + remote_manifest: OfficialLauncherRemoteManifestSnapshot { + url: manifest_url.to_string(), + source: manifest.source.clone(), + file_count: manifest.files.len(), + files_blake3: launcher_manifest_files_blake3(&manifest.files), + files: manifest + .files + .iter() + .map(launcher_manifest_file_snapshot) + .collect(), + }, + selected_game_main_config_source: launcher_game_main_config_source_snapshot( + selected_source, + ), + } +} + +fn launcher_bootstrap_context( + snapshot: &OfficialUpdateSnapshot, +) -> OfficialLauncherBootstrapContext { + OfficialLauncherBootstrapContext { + connection_group_name: snapshot.connection_group_name.clone(), + app_version: snapshot.app_version.clone(), + bundle_version: snapshot.bundle_version.clone(), + addressables_root: snapshot.addressables_root.clone(), + } +} + +fn launcher_bootstrap_artifact( + snapshot: &OfficialUpdateSnapshot, + bootstrap: &ResolvedBootstrap, + status: OfficialLauncherBootstrapArtifactStatus, +) -> OfficialLauncherBootstrapArtifact { + OfficialLauncherBootstrapArtifact { + artifact_version: OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION, + status, + generated_unix_seconds: unix_seconds_now(), + context: launcher_bootstrap_context(snapshot), + launcher_bootstrap: bootstrap.launcher_bootstrap.clone(), + } +} + +fn write_launcher_bootstrap_artifact_for_snapshot( + config: &OfficialUpdateConfig, + root: &Path, + snapshot: &OfficialUpdateSnapshot, + bootstrap: Option<&ResolvedBootstrap>, + status: OfficialLauncherBootstrapArtifactStatus, + file_name: &str, +) -> anyhow::Result> { + let Some(bootstrap) = bootstrap else { + return Ok(None); + }; + let path = root.join(file_name); + ensure_safe_file_target(&config.output_root, &path, "官方启动器 bootstrap 产物") + .map_err(anyhow::Error::msg)?; + let artifact = launcher_bootstrap_artifact(snapshot, bootstrap, status); + write_launcher_bootstrap_artifact(&path, &artifact)?; + Ok(Some(path)) +} + /// 官方引导链路使用的外部命令与代理配置。 struct BootstrapTools<'a> { curl_command: &'a Path, @@ -2618,13 +3802,29 @@ fn resolve_bootstrap( .fetch_latest_remote_manifest() .map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; + let cdn_config = launcher.fetch_cdn_config().map_err(anyhow::Error::new)?; + check_shutdown_requested(should_cancel)?; let launcher_metadata = launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest); + let manifest_source = launcher_metadata + .manifest_source + .as_deref() + .ok_or_else(|| { + anyhow::Error::new(DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + "官方启动器远端 manifest 缺少 source", + )) + })?; + let selected_source = + resolve_game_main_config_source(&game_config, &manifest, manifest_source, &cdn_config) + .map_err(anyhow::Error::new)?; progress(OfficialUpdateProgress::new( "launcher", format!( - "启动器元数据已解析:最新版本={} manifest 文件数={}", - launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count + "启动器元数据已解析:最新版本={} manifest 文件数={} CDN={}", + launcher_metadata.game_latest_version, + launcher_metadata.manifest_file_count, + cdn_config.primary_cdn ), )); @@ -2640,9 +3840,18 @@ fn resolve_bootstrap( "bootstrap-cache", "命中缓存;复用已解析的 GameMainConfig", )); + let launcher_bootstrap = launcher_bootstrap_data_from_parts( + launcher_metadata.clone(), + game_main_config.clone(), + &cdn_config, + &manifest_url.url, + &manifest, + &selected_source, + ); return Ok(ResolvedBootstrap { launcher_metadata, game_main_config, + launcher_bootstrap, cache_hit: true, }); } @@ -2659,20 +3868,24 @@ fn resolve_bootstrap( tools.unzip_command.to_path_buf(), ) .with_proxy_config(tools.curl_proxy.clone()); - let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::new)?; + let bootstrap = bootstrapper + .fetch_bootstrap_from_parts( + game_config.clone(), + cdn_config.clone(), + manifest_url.clone(), + manifest.clone(), + ) + .map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; - let launcher_metadata = LauncherMetadataSnapshot { - launcher_version: launcher_version.to_string(), - game_latest_version: bootstrap.game_config.game_latest_version.clone(), - game_latest_file_path: bootstrap.game_config.game_latest_file_path.clone(), - game_lowest_version: bootstrap.game_config.game_lowest_version.clone(), - game_start_exe_name: bootstrap.game_config.game_start_exe_name.clone(), - game_start_params: bootstrap.game_config.game_start_params.clone(), - manifest_url: bootstrap.manifest_url.clone(), - manifest_source: bootstrap.manifest_source.clone(), - manifest_file_count: bootstrap.manifest_file_count, - }; let game_main_config = game_main_config_snapshot(&bootstrap.game_main_config); + let launcher_bootstrap = launcher_bootstrap_data_from_parts( + launcher_metadata.clone(), + game_main_config.clone(), + &bootstrap.cdn_config, + &bootstrap.manifest_url, + &bootstrap.remote_manifest, + &bootstrap.selected_source, + ); if write_cache { write_bootstrap_cache( cache_path, @@ -2696,6 +3909,7 @@ fn resolve_bootstrap( Ok(ResolvedBootstrap { launcher_metadata, game_main_config, + launcher_bootstrap, cache_hit: false, }) } @@ -2728,9 +3942,15 @@ fn fetch_seed_catalogs( endpoint.url ), )); - let bytes = fetcher - .fetch_bytes(&endpoint.url) - .map_err(anyhow::Error::new)?; + let bytes = match fetcher.fetch_bytes(&endpoint.url) { + Ok(bytes) => bytes, + Err(error) if is_official_resource_not_ready(&error) => { + return Err(anyhow::Error::new(OfficialResourceUnavailable::new( + endpoint, &error, + ))); + } + Err(error) => return Err(anyhow::Error::new(error)), + }; catalogs.insert(endpoint, bytes)?; } @@ -3055,6 +4275,7 @@ mod tests { manifest_url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json".to_string(), manifest_source: Some("BAJP_1.70.0.zip".to_string()), manifest_file_count: 42, + manifest_files_blake3: Some("manifest-digest".to_string()), }, game_main_config: GameMainConfigSnapshot { server_info_data_url: Some( @@ -3062,6 +4283,53 @@ mod tests { ), default_connection_group: Some("Prod-Audit".to_string()), }, + launcher_bootstrap: OfficialLauncherBootstrapData { + launcher_metadata: LauncherMetadataSnapshot { + launcher_version: "1.7.2".to_string(), + game_latest_version: "1.70.0".to_string(), + game_latest_file_path: "BAJP_1.70.0.zip".to_string(), + game_lowest_version: Some("1.69.0".to_string()), + game_start_exe_name: Some("BlueArchive".to_string()), + game_start_params: vec!["--prod".to_string()], + manifest_url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json" + .to_string(), + manifest_source: Some("BAJP_1.70.0.zip".to_string()), + manifest_file_count: 42, + manifest_files_blake3: Some("manifest-digest".to_string()), + }, + game_main_config: GameMainConfigSnapshot { + server_info_data_url: Some( + "https://yostar-serverinfo.bluearchiveyostar.com/prod.json".to_string(), + ), + default_connection_group: Some("Prod-Audit".to_string()), + }, + cdn_config: OfficialLauncherCdnConfigSnapshot { + primary_cdn: "https://launcher-pkg-ba-jp.yo-star.com".to_string(), + back_up_cdn: "https://launcher-pkg-ba-jp-bk.yo-star.com".to_string(), + }, + remote_manifest: OfficialLauncherRemoteManifestSnapshot { + url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json".to_string(), + source: Some("BAJP_1.70.0.zip".to_string()), + file_count: 1, + files_blake3: "manifest-digest".to_string(), + files: vec![OfficialLauncherManifestFileSnapshot { + path: "/BlueArchive_Data/resources.assets".to_string(), + size: "123".to_string(), + parsed_size: Some(123), + hash: "official-hash".to_string(), + vc: Some("vc".to_string()), + }], + }, + selected_game_main_config_source: OfficialLauncherGameMainConfigSourceSnapshot { + kind: OfficialLauncherGameMainConfigSourceKind::Archive, + url: "https://launcher-pkg-ba-jp.yo-star.com/BAJP_1.70.0.zip".to_string(), + relative_path: "BAJP_1.70.0.zip".to_string(), + manifest_path: None, + declared_size: None, + official_hash: None, + vc: None, + }, + }, cache_hit: false, } } @@ -3129,6 +4397,106 @@ mod tests { assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache)); } + #[test] + fn bootstrap_cache_misses_when_launcher_manifest_digest_changes() { + let bootstrap = fixture_bootstrap(); + let cache = OfficialBootstrapCache { + cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION, + launcher_metadata: bootstrap.launcher_metadata.clone(), + game_main_config: bootstrap.game_main_config.clone(), + }; + let mut changed_metadata = bootstrap.launcher_metadata.clone(); + changed_metadata.manifest_files_blake3 = Some("different-manifest-digest".to_string()); + + assert!(cached_game_main_config_for_metadata(&cache, &changed_metadata).is_none()); + } + + #[test] + fn launcher_manifest_file_digest_changes_with_manifest_content() { + let files = vec![YostarJpLauncherManifestFile { + path: "/BlueArchive_Data/resources.assets".to_string(), + size: "123".to_string(), + hash: "hash-a".to_string(), + vc: Some("vc".to_string()), + }]; + let mut changed = files.clone(); + changed[0].hash = "hash-b".to_string(); + + assert_ne!( + launcher_manifest_files_blake3(&files), + launcher_manifest_files_blake3(&changed) + ); + } + + #[test] + fn persists_launcher_bootstrap_artifact_json_round_trip() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("official-launcher-bootstrap.json"); + let bootstrap = fixture_bootstrap(); + let snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("1234")], + Some(&bootstrap), + ); + let artifact = launcher_bootstrap_artifact( + &snapshot, + &bootstrap, + OfficialLauncherBootstrapArtifactStatus::Published, + ); + + write_launcher_bootstrap_artifact(&path, &artifact).unwrap(); + let read = read_launcher_bootstrap_artifact(&path).unwrap().unwrap(); + + assert_eq!(read, artifact); + assert_eq!( + read.launcher_bootstrap + .selected_game_main_config_source + .relative_path, + "BAJP_1.70.0.zip" + ); + assert_eq!( + read.launcher_bootstrap.remote_manifest.files[0].parsed_size, + Some(123) + ); + } + + #[test] + fn writes_pending_launcher_bootstrap_artifact_under_output_root() { + let temp = tempfile::TempDir::new().unwrap(); + let config = OfficialUpdateConfig { + output_root: temp.path().to_path_buf(), + ..OfficialUpdateConfig::default() + }; + let bootstrap = fixture_bootstrap(); + let snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("1234")], + Some(&bootstrap), + ); + + let path = write_launcher_bootstrap_artifact_for_snapshot( + &config, + temp.path(), + &snapshot, + Some(&bootstrap), + OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources, + OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE, + ) + .unwrap() + .unwrap(); + let artifact = read_launcher_bootstrap_artifact(&path).unwrap().unwrap(); + + assert_eq!( + path, + temp.path().join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE) + ); + assert_eq!( + artifact.status, + OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources + ); + assert_eq!(artifact.context.app_version, "1.70.0"); + } + #[test] fn persists_version_state_json_round_trip() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/infrastructure/src/patch_ops.rs b/infrastructure/src/patch_ops.rs new file mode 100644 index 0000000..9ed3573 --- /dev/null +++ b/infrastructure/src/patch_ops.rs @@ -0,0 +1,424 @@ +//! File-level Patch and UnityFS write operations. + +use std::path::{Path, PathBuf}; + +use bat_assetbundle::{ + patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, + StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue, +}; +use serde::{Deserialize, Serialize}; + +use crate::path_security::{ + ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic, + STATE_FILE_MODE, +}; + +/// File patch algorithm selected by `patch.apply`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PatchApplyKind { + /// Deterministic binary hunk patch. + Binary, + /// RFC 6902 JSON Patch. + Json, + /// UTF-8 Text Patch. + Text, +} + +impl PatchApplyKind { + /// Stable RPC/CLI label. + pub fn as_str(self) -> &'static str { + match self { + Self::Binary => "binary", + Self::Json => "json", + Self::Text => "text", + } + } +} + +/// Parameters for applying one file-level patch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PatchApplyParams { + /// Patch algorithm. + pub kind: PatchApplyKind, + /// Source file path. + pub source_path: PathBuf, + /// Patch document path. + pub patch_path: PathBuf, + /// Target file path written atomically. + pub target_path: PathBuf, +} + +/// Parameters for patching one UnityFS TextAsset object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnityFsTextAssetPatchParams { + /// Source UnityFS bundle file. + pub bundle_path: PathBuf, + /// Serialized file path inside the UnityFS directory table. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Replacement raw bytes path. + pub replacement_path: PathBuf, + /// Target bundle file path written atomically. + pub target_path: PathBuf, + /// Optional expected TextAsset name. + #[serde(default)] + pub expected_name: Option, +} + +/// Parameters for patching one TypeTree string field inside a UnityFS bundle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnityFsStringFieldPatchParams { + /// Source UnityFS bundle file. + pub bundle_path: PathBuf, + /// Serialized file path inside the UnityFS directory table. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Stable TypeTree field path. + pub field_path: String, + /// Replacement string supplied inline. + #[serde(default)] + pub replacement_text: Option, + /// Replacement UTF-8 file path. + #[serde(default)] + pub replacement_path: Option, + /// Target bundle file path written atomically. + pub target_path: PathBuf, + /// Optional expected source string. + #[serde(default)] + pub expected_value: Option, +} + +/// Parameters for patching one semantic TypeTree field inside a UnityFS bundle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnityFsFieldPatchParams { + /// Source UnityFS bundle file. + pub bundle_path: PathBuf, + /// Serialized file path inside the UnityFS directory table. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Stable TypeTree field path. + pub field_path: String, + /// Replacement value encoded according to the current TypeTree field type. + pub replacement: UnitySerializedReplacementValue, + /// Target bundle file path written atomically. + pub target_path: PathBuf, + /// Optional expected source value. + #[serde(default)] + pub expected_value: Option, +} + +/// Result of a file-level patch operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PatchApplyReport { + /// RPC/CLI command name. + pub command: &'static str, + /// Operation status. + pub status: &'static str, + /// Human-readable summary. + pub message: &'static str, + /// Patch algorithm. + pub kind: PatchApplyKind, + /// Absolute source path. + pub source_path: PathBuf, + /// Absolute patch path. + pub patch_path: PathBuf, + /// Absolute target path. + pub target_path: PathBuf, + /// Source byte length. + pub source_size: u64, + /// Patch document byte length. + pub patch_size: u64, + /// Target byte length. + pub target_size: u64, + /// Source BLAKE3 hash. + pub source_blake3: String, + /// Patch document BLAKE3 hash. + pub patch_blake3: String, + /// Target BLAKE3 hash. + pub target_blake3: String, +} + +/// Result of a UnityFS write operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UnityFsPatchReport { + /// RPC/CLI command name. + pub command: &'static str, + /// Operation status. + pub status: &'static str, + /// Human-readable summary. + pub message: &'static str, + /// Absolute source UnityFS bundle path. + pub bundle_path: PathBuf, + /// Serialized file path inside the UnityFS directory table. + pub serialized_file_path: String, + /// Unity object path ID. + pub path_id: i64, + /// Optional TypeTree field path for string-field patches. + #[serde(skip_serializing_if = "Option::is_none")] + pub field_path: Option, + /// Absolute target bundle path. + pub target_path: PathBuf, + /// Source bundle byte length. + pub source_size: u64, + /// Replacement byte length. + pub replacement_size: u64, + /// Target bundle byte length. + pub target_size: u64, + /// Source bundle BLAKE3 hash. + pub source_blake3: String, + /// Replacement BLAKE3 hash. + pub replacement_blake3: String, + /// Target bundle BLAKE3 hash. + pub target_blake3: String, +} + +/// Applies a Binary/JSON/Text patch document to one source file. +pub fn apply_patch_file(params: &PatchApplyParams) -> anyhow::Result { + let source_path = lexical_absolute(¶ms.source_path).map_err(anyhow::Error::msg)?; + let patch_path = lexical_absolute(¶ms.patch_path).map_err(anyhow::Error::msg)?; + let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?; + ensure_target_is_not_input(&target_path, &[&source_path, &patch_path])?; + + let source = read_required_file(&source_path, "patch source")?; + let patch = read_required_file(&patch_path, "patch document")?; + let target = match params.kind { + PatchApplyKind::Binary => bat_patch::binary::apply_patch(&source, &patch)?, + PatchApplyKind::Json => { + let source = std::str::from_utf8(&source) + .map_err(|error| anyhow::anyhow!("JSON patch source 不是 UTF-8:{error}"))?; + let patch = std::str::from_utf8(&patch) + .map_err(|error| anyhow::anyhow!("JSON patch document 不是 UTF-8:{error}"))?; + bat_patch::json::apply_json_patch(source, patch)?.into_bytes() + } + PatchApplyKind::Text => bat_patch::text::apply_patch_bytes(&source, &patch)?, + }; + write_output_file(&target_path, &target, "patch target")?; + + Ok(PatchApplyReport { + command: "patch.apply", + status: "patched", + message: "patch 已应用并原子写入目标文件", + kind: params.kind, + source_path, + patch_path, + target_path, + source_size: source.len() as u64, + patch_size: patch.len() as u64, + target_size: target.len() as u64, + source_blake3: blake3_hex(&source), + patch_blake3: blake3_hex(&patch), + target_blake3: blake3_hex(&target), + }) +} + +/// Patches one UnityFS TextAsset and writes the rebuilt bundle atomically. +pub fn apply_unityfs_text_asset_patch_file( + params: &UnityFsTextAssetPatchParams, +) -> anyhow::Result { + let bundle_path = lexical_absolute(¶ms.bundle_path).map_err(anyhow::Error::msg)?; + let replacement_path = + lexical_absolute(¶ms.replacement_path).map_err(anyhow::Error::msg)?; + let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?; + ensure_target_is_not_input(&target_path, &[&bundle_path, &replacement_path])?; + + let bundle = read_required_file(&bundle_path, "UnityFS bundle")?; + let replacement = read_required_file(&replacement_path, "TextAsset replacement")?; + let mut patch = TextAssetPatch::new( + params.serialized_file_path.clone(), + params.path_id, + replacement.clone(), + ); + patch.expected_name = params.expected_name.clone(); + let target = patch_unityfs_text_asset(&bundle, &patch)?; + write_output_file(&target_path, &target, "UnityFS target")?; + + Ok(UnityFsPatchReport { + command: "unityfs.patch_text_asset", + status: "patched", + message: "UnityFS TextAsset patch 已应用并原子写入目标 bundle", + bundle_path, + serialized_file_path: params.serialized_file_path.clone(), + path_id: params.path_id, + field_path: None, + target_path, + source_size: bundle.len() as u64, + replacement_size: replacement.len() as u64, + target_size: target.len() as u64, + source_blake3: blake3_hex(&bundle), + replacement_blake3: blake3_hex(&replacement), + target_blake3: blake3_hex(&target), + }) +} + +/// Patches one TypeTree string field and writes the rebuilt UnityFS bundle atomically. +pub fn apply_unityfs_string_field_patch_file( + params: &UnityFsStringFieldPatchParams, +) -> anyhow::Result { + let bundle_path = lexical_absolute(¶ms.bundle_path).map_err(anyhow::Error::msg)?; + let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?; + let replacement = replacement_text(params)?; + let extra_inputs = params + .replacement_path + .as_ref() + .map(|path| lexical_absolute(path).map_err(anyhow::Error::msg)) + .transpose()?; + let mut inputs = vec![bundle_path.as_path()]; + if let Some(path) = extra_inputs.as_ref() { + inputs.push(path.as_path()); + } + ensure_target_is_not_input(&target_path, &inputs)?; + + let bundle = read_required_file(&bundle_path, "UnityFS bundle")?; + let patch = StringFieldPatch { + serialized_file_path: params.serialized_file_path.clone(), + path_id: params.path_id, + field_path: params.field_path.clone(), + expected_value: params.expected_value.clone(), + replacement: replacement.clone(), + }; + let target = patch_unityfs_string_field(&bundle, &patch)?; + write_output_file(&target_path, &target, "UnityFS target")?; + + Ok(UnityFsPatchReport { + command: "unityfs.patch_string_field", + status: "patched", + message: "UnityFS TypeTree string field patch 已应用并原子写入目标 bundle", + bundle_path, + serialized_file_path: params.serialized_file_path.clone(), + path_id: params.path_id, + field_path: Some(params.field_path.clone()), + target_path, + source_size: bundle.len() as u64, + replacement_size: replacement.len() as u64, + target_size: target.len() as u64, + source_blake3: blake3_hex(&bundle), + replacement_blake3: blake3_hex(replacement.as_bytes()), + target_blake3: blake3_hex(&target), + }) +} + +/// Patches one semantic TypeTree field and writes the rebuilt UnityFS bundle atomically. +pub fn apply_unityfs_field_patch_file( + params: &UnityFsFieldPatchParams, +) -> anyhow::Result { + let bundle_path = lexical_absolute(¶ms.bundle_path).map_err(anyhow::Error::msg)?; + let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?; + ensure_target_is_not_input(&target_path, &[&bundle_path])?; + + let bundle = read_required_file(&bundle_path, "UnityFS bundle")?; + let replacement_size = serde_json::to_vec(¶ms.replacement) + .map_err(anyhow::Error::from)? + .len() as u64; + let patch = FieldPatch { + serialized_file_path: params.serialized_file_path.clone(), + path_id: params.path_id, + field_path: params.field_path.clone(), + expected_value: params.expected_value.clone(), + replacement: params.replacement.clone(), + }; + let target = patch_unityfs_field(&bundle, &patch)?; + write_output_file(&target_path, &target, "UnityFS target")?; + + Ok(UnityFsPatchReport { + command: "unityfs.patch_field", + status: "patched", + message: "UnityFS TypeTree field patch 已应用并原子写入目标 bundle", + bundle_path, + serialized_file_path: params.serialized_file_path.clone(), + path_id: params.path_id, + field_path: Some(params.field_path.clone()), + target_path, + source_size: bundle.len() as u64, + replacement_size, + target_size: target.len() as u64, + source_blake3: blake3_hex(&bundle), + replacement_blake3: blake3_hex(&serde_json::to_vec(¶ms.replacement)?), + target_blake3: blake3_hex(&target), + }) +} + +fn replacement_text(params: &UnityFsStringFieldPatchParams) -> anyhow::Result { + match (¶ms.replacement_text, ¶ms.replacement_path) { + (Some(_), Some(_)) => Err(anyhow::anyhow!( + "replacement_text 和 replacement_path 只能指定一个" + )), + (Some(text), None) => Ok(text.clone()), + (None, Some(path)) => { + let path = lexical_absolute(path).map_err(anyhow::Error::msg)?; + let bytes = read_required_file(&path, "string replacement")?; + String::from_utf8(bytes) + .map_err(|error| anyhow::anyhow!("string replacement 不是 UTF-8:{error}")) + } + (None, None) => Err(anyhow::anyhow!( + "必须指定 replacement_text 或 replacement_path" + )), + } +} + +fn read_required_file(path: &Path, label: &str) -> anyhow::Result> { + read_file_no_symlink(path, label) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("{label} 不存在:{}", path.display())) +} + +fn write_output_file(path: &Path, bytes: &[u8], label: &str) -> anyhow::Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("{label} 缺少父目录:{}", path.display()))?; + ensure_safe_file_target(parent, path, label).map_err(anyhow::Error::msg)?; + write_file_atomic(path, bytes, STATE_FILE_MODE, label).map_err(anyhow::Error::msg) +} + +fn ensure_target_is_not_input(target: &Path, inputs: &[&Path]) -> anyhow::Result<()> { + for input in inputs { + if target == *input { + return Err(anyhow::anyhow!( + "target_path 不能与输入文件相同:{}", + target.display() + )); + } + } + Ok(()) +} + +fn blake3_hex(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patch_apply_rejects_in_place_target() { + let path = PathBuf::from("/tmp/source.bin"); + let params = PatchApplyParams { + kind: PatchApplyKind::Binary, + source_path: path.clone(), + patch_path: PathBuf::from("/tmp/patch.json"), + target_path: path, + }; + let error = apply_patch_file(¶ms).unwrap_err().to_string(); + assert!(error.contains("target_path 不能与输入文件相同")); + } + + #[test] + fn string_field_params_require_one_replacement_source() { + let params = UnityFsStringFieldPatchParams { + bundle_path: PathBuf::from("/tmp/source.bundle"), + serialized_file_path: "CAB".to_string(), + path_id: 1, + field_path: "message".to_string(), + replacement_text: Some("a".to_string()), + replacement_path: Some(PathBuf::from("/tmp/replacement.txt")), + target_path: PathBuf::from("/tmp/target.bundle"), + expected_value: None, + }; + let error = replacement_text(¶ms).unwrap_err().to_string(); + assert!(error.contains("只能指定一个")); + } +} diff --git a/infrastructure/src/resources.rs b/infrastructure/src/resources.rs index 909bff7..0bf319f 100644 --- a/infrastructure/src/resources.rs +++ b/infrastructure/src/resources.rs @@ -1,7 +1,7 @@ //! 内存资源仓储实现。 use async_trait::async_trait; -use bat_core::domain::{Resource, ResourceEntry, ResourceType}; +use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType}; use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult}; use sqlx::{QueryBuilder, Sqlite, SqlitePool}; @@ -130,7 +130,8 @@ impl SqliteResourceRepository { local_path TEXT NOT NULL, address TEXT, dependencies_json TEXT NOT NULL DEFAULT '[]', - crc INTEGER + crc INTEGER, + metadata_json TEXT NOT NULL DEFAULT '{}' ) "#, ), @@ -140,6 +141,13 @@ impl SqliteResourceRepository { // 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列, // pragma 检查后不会重复 ALTER)。 Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?; + Self::ensure_column( + &self.pool, + "resources", + "metadata_json", + "TEXT NOT NULL DEFAULT '{}'", + ) + .await?; Self::execute_query( &self.pool, @@ -250,9 +258,32 @@ impl SqliteResourceRepository { .map_err(|error| bat_core::Error::Serialization(error.to_string())) } + fn metadata_to_json(metadata: &ResourceMetadata) -> bat_core::Result { + serde_json::to_string(metadata) + .map_err(|error| bat_core::Error::Serialization(error.to_string())) + } + + fn metadata_from_json(value: &str) -> bat_core::Result { + if value.trim().is_empty() { + return Ok(ResourceMetadata::default()); + } + serde_json::from_str(value) + .map_err(|error| bat_core::Error::Serialization(error.to_string())) + } + fn resource_from_row(row: ResourceRow) -> bat_core::Result { - let (id, path, hash, size, resource_type, local_path, address, dependencies_json, crc) = - row; + let ( + id, + path, + hash, + size, + resource_type, + local_path, + address, + dependencies_json, + crc, + metadata_json, + ) = row; Ok(Resource { id, local_path: PathBuf::from(local_path), @@ -265,6 +296,7 @@ impl SqliteResourceRepository { dependencies: Self::dependencies_from_json(&dependencies_json)?, crc: crc.and_then(|value| u32::try_from(value).ok()), }, + metadata: Self::metadata_from_json(&metadata_json)?, }) } @@ -303,7 +335,7 @@ impl SqliteResourceRepository { limit: Option, ) -> bat_core::Result> { let mut builder = QueryBuilder::::new( - "SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources", + "SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json FROM resources", ); Self::apply_filters(&mut builder, query)?; builder.push(" ORDER BY id"); @@ -337,14 +369,15 @@ impl SqliteResourceRepository { impl ResourceRepository for SqliteResourceRepository { async fn add(&self, resource: Resource) -> bat_core::Result { let dependencies = Self::dependencies_to_json(&resource.entry.dependencies)?; + let metadata = Self::metadata_to_json(&resource.metadata)?; Self::execute_query( &self.pool, sqlx::query( r#" INSERT INTO resources ( - id, path, hash, size, resource_type, local_path, address, dependencies_json, crc + id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(id) DO UPDATE SET path = excluded.path, hash = excluded.hash, @@ -353,7 +386,8 @@ impl ResourceRepository for SqliteResourceRepository { local_path = excluded.local_path, address = excluded.address, dependencies_json = excluded.dependencies_json, - crc = excluded.crc + crc = excluded.crc, + metadata_json = excluded.metadata_json "#, ) .bind(resource.id.clone()) @@ -364,7 +398,8 @@ impl ResourceRepository for SqliteResourceRepository { .bind(resource.local_path.to_string_lossy().to_string()) .bind(resource.entry.address.clone()) .bind(dependencies) - .bind(resource.entry.crc.map(i64::from)), + .bind(resource.entry.crc.map(i64::from)) + .bind(metadata), ) .await?; @@ -374,7 +409,7 @@ impl ResourceRepository for SqliteResourceRepository { async fn find_by_id(&self, id: &str) -> bat_core::Result { let row: Option = sqlx::query_as( r#" - SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc + SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json FROM resources WHERE id = ?1 "#, @@ -392,7 +427,7 @@ impl ResourceRepository for SqliteResourceRepository { async fn find_by_hash(&self, hash: &str) -> bat_core::Result { let row: Option = sqlx::query_as( r#" - SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc + SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json FROM resources WHERE hash = ?1 ORDER BY id @@ -455,6 +490,7 @@ type ResourceRow = ( Option, String, Option, + String, ); fn glob_to_like(pattern: &str) -> String { @@ -544,6 +580,7 @@ mod tests { dependencies: Vec::new(), crc: None, }, + metadata: ResourceMetadata::default(), } } @@ -638,6 +675,10 @@ mod tests { .entry .dependencies .push("assets/shared.bundle".to_string()); + resource.metadata.official_release_id = Some("release-1".to_string()); + resource.metadata.platform = Some("windows".to_string()); + resource.metadata.text_assets = vec!["Scenario".to_string()]; + resource.metadata.text_unit_count = 3; repository.add(resource.clone()).await.unwrap(); @@ -647,6 +688,13 @@ mod tests { by_id.entry.dependencies, vec!["assets/shared.bundle".to_string()] ); + assert_eq!( + by_id.metadata.official_release_id.as_deref(), + Some("release-1") + ); + assert_eq!(by_id.metadata.platform.as_deref(), Some("windows")); + assert_eq!(by_id.metadata.text_assets, vec!["Scenario".to_string()]); + assert_eq!(by_id.metadata.text_unit_count, 3); assert_eq!( repository.find_by_hash("hash-sqlite-a").await.unwrap().id, resource.id diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index 92e2292..51e2b96 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -367,14 +367,16 @@ fn official_update_reuses_failed_staging_after_interrupted_download() { let curl_failure_state = harness.temp.path().join("curl-failure.state"); write_executable( &harness.curl_script, - &official_curl_script( - &harness.curl_log, - &zip_fixture, - &resources_assets_path, - fs::metadata(&resources_assets_path).unwrap().len() as usize, - TEST_LAUNCHER_MANIFEST_SOURCE, - Some(&curl_failure_state), - ), + &official_curl_script(OfficialCurlScriptFixture { + curl_log: &harness.curl_log, + zip_fixture: &zip_fixture, + resources_assets_fixture: &resources_assets_path, + resources_assets_size: fs::metadata(&resources_assets_path).unwrap().len() as usize, + manifest_source: TEST_LAUNCHER_MANIFEST_SOURCE, + failure_state: Some(&curl_failure_state), + clientpatch_unavailable: false, + seed_unavailable_only: false, + }), ); let config = harness.sync_config("failed-staging-output"); @@ -399,6 +401,77 @@ fn official_update_reuses_failed_staging_after_interrupted_download() { assert!(version_state.current_completed_version.is_some()); } +#[test] +fn official_update_waits_when_client_patch_markers_are_not_ready() { + let harness = TestHarness::new_with_clientpatch_unavailable(false); + let config = harness.sync_config("clientpatch-marker-wait-output"); + let mut events = Vec::::new(); + + let report = OfficialUpdateService::new() + .run_with_progress(&config, |event| events.push(event)) + .unwrap(); + + assert_eq!( + report.update_status, + OfficialUpdateStatus::WaitingForOfficialResources + ); + assert!(report.waiting_for_official_resources); + assert!(!report.should_download); + assert!(report + .unavailable_endpoints + .iter() + .any(|endpoint| endpoint.url.ends_with("/TableBundles/TableCatalog.hash"))); + assert!(report + .unavailable_endpoints + .iter() + .all(|endpoint| endpoint.error_kind == "http_forbidden")); + assert!(report.staging_path.is_none()); + assert!(report.published_version_path.is_none()); + assert!(!config.output_root.join("current").exists()); + assert!(!config.output_root.join(".staging").exists()); + assert!(read_version_state(&report.version_state_path) + .unwrap() + .is_none()); + assert!(events + .iter() + .any(|event| event.stage == "upstream" && event.message.contains("尚未开放"))); +} + +#[test] +fn official_update_waits_when_required_seed_catalog_is_not_ready() { + let harness = TestHarness::new_with_clientpatch_unavailable(true); + let config = harness.sync_config("clientpatch-seed-wait-output"); + let mut events = Vec::::new(); + + let report = OfficialUpdateService::new() + .run_with_progress(&config, |event| events.push(event)) + .unwrap(); + + assert_eq!( + report.update_status, + OfficialUpdateStatus::WaitingForOfficialResources + ); + assert!(report.waiting_for_official_resources); + assert_eq!(report.unavailable_endpoints.len(), 1); + assert!(report.unavailable_endpoints[0] + .url + .ends_with("/TableBundles/TableCatalog.bytes")); + assert_eq!(report.unavailable_endpoints[0].http_status, Some(403)); + assert!(report.staging_path.is_none()); + assert!(report.published_version_path.is_none()); + assert!(!config.output_root.join("current").exists()); + assert!(!config.output_root.join(".staging").exists()); + assert!(read_version_state(&report.version_state_path) + .unwrap() + .is_none()); + assert!(events + .iter() + .any(|event| event.stage == "catalog" && event.message.contains("TableCatalog.bytes"))); + assert!(events + .iter() + .any(|event| event.stage == "upstream" && event.message.contains("必需资源"))); +} + struct TestHarness { temp: TempDir, curl_script: std::path::PathBuf, @@ -412,6 +485,18 @@ impl TestHarness { } fn new_with_manifest_source(manifest_source: &str) -> Self { + Self::new_with_options(manifest_source, false, false) + } + + fn new_with_clientpatch_unavailable(seed_only: bool) -> Self { + Self::new_with_options(TEST_LAUNCHER_MANIFEST_SOURCE, true, seed_only) + } + + fn new_with_options( + manifest_source: &str, + clientpatch_unavailable: bool, + seed_unavailable_only: bool, + ) -> Self { let temp = TempDir::new().unwrap(); let resources_assets = synthetic_resources_assets(); @@ -425,14 +510,16 @@ impl TestHarness { let curl_script = temp.path().join("fake-curl"); write_executable( &curl_script, - &official_curl_script( - &curl_log, - &zip_fixture, - &resources_assets_path, - resources_assets.len(), + &official_curl_script(OfficialCurlScriptFixture { + curl_log: &curl_log, + zip_fixture: &zip_fixture, + resources_assets_fixture: &resources_assets_path, + resources_assets_size: resources_assets.len(), manifest_source, - None, - ), + failure_state: None, + clientpatch_unavailable, + seed_unavailable_only, + }), ); let unzip_script = temp.path().join("fake-unzip"); @@ -542,15 +629,20 @@ fn one_file_zip(name: &[u8], data: &[u8]) -> Vec { bytes } -fn official_curl_script( - curl_log: &Path, - zip_fixture: &Path, - resources_assets_fixture: &Path, +struct OfficialCurlScriptFixture<'a> { + curl_log: &'a Path, + zip_fixture: &'a Path, + resources_assets_fixture: &'a Path, resources_assets_size: usize, - manifest_source: &str, - failure_state: Option<&Path>, -) -> String { - let failure_state = failure_state + manifest_source: &'a str, + failure_state: Option<&'a Path>, + clientpatch_unavailable: bool, + seed_unavailable_only: bool, +} + +fn official_curl_script(fixture: OfficialCurlScriptFixture<'_>) -> String { + let failure_state = fixture + .failure_state .map(shell_quote) .unwrap_or_else(|| "''".to_string()); format!( @@ -614,6 +706,31 @@ maybe_fail_once() {{ fi }} +clientpatch_is_unavailable() {{ + if [[ "{clientpatch_unavailable}" != "true" ]]; then + return 1 + fi + if [[ "$url" != "{addressables_root}/"* ]]; then + return 1 + fi + if [[ "{seed_unavailable_only}" == "true" ]]; then + case "$url" in + */TableBundles/TableCatalog.bytes|*/BundlePackingInfo.bytes|*/Catalog/MediaCatalog.bytes) + return 0 + ;; + *) + return 1 + ;; + esac + fi + return 0 +}} + +if clientpatch_is_unavailable; then + echo "curl: (22) The requested URL returned error: 403" >&2 + exit 22 +fi + if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then cat <<'JSON' {{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}} @@ -679,16 +796,16 @@ else exit 1 fi "#, - shell_quote(curl_log), - shell_quote(zip_fixture), - shell_quote(resources_assets_fixture), + shell_quote(fixture.curl_log), + shell_quote(fixture.zip_fixture), + shell_quote(fixture.resources_assets_fixture), launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION, launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH, launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL, launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL, - launcher_manifest_source = manifest_source, + launcher_manifest_source = fixture.manifest_source, launcher_resources_assets_url = TEST_LAUNCHER_RESOURCES_ASSETS_URL, - resources_assets_size = resources_assets_size, + resources_assets_size = fixture.resources_assets_size, server_info_url = TEST_SERVER_INFO_URL, connection_group = TEST_CONNECTION_GROUP, addressables_root = TEST_ADDRESSABLES_ROOT, @@ -698,6 +815,8 @@ fi android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"), android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"), failure_state = failure_state, + clientpatch_unavailable = fixture.clientpatch_unavailable, + seed_unavailable_only = fixture.seed_unavailable_only, ) }