feat(sync): 接入解析缓存与汉化发布前置

补齐官方 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。
This commit is contained in:
2026-07-31 00:38:45 +08:00
parent f4880a71bd
commit 2079c6a307
25 changed files with 17287 additions and 225 deletions
@@ -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::<OfficialUpdateProgress>::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::<OfficialUpdateProgress>::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<u8> {
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,
)
}