feat: track official versions and import resources

Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
This commit is contained in:
2026-07-15 19:53:10 +08:00
parent 4192d7ee4c
commit 90307a3243
30 changed files with 1269 additions and 157 deletions
+55 -5
View File
@@ -187,12 +187,32 @@ impl AddressablesCatalogDriver {
}
fn resource_type_for_path(path: &str) -> ResourceType {
if path.contains("TableBundles/") || path.ends_with(".bytes") {
ResourceType::TableBundle
} else if path.ends_with(".bundle") {
ResourceType::AssetBundle
} else if path.contains("catalog") {
let normalized = path.replace('\\', "/").to_ascii_lowercase();
if normalized.contains("catalog") || normalized.ends_with(".hash") {
ResourceType::Manifest
} else if normalized.contains("tablebundles/") {
ResourceType::TableBundle
} else if normalized.contains("mediaresources/")
|| normalized.contains("mediaresources-")
|| matches!(
normalized.rsplit('.').next(),
Some(
"mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb"
)
)
{
ResourceType::Media
} else if normalized.ends_with(".bundle") {
ResourceType::AssetBundle
} else if normalized.contains("textassets/")
|| matches!(
normalized.rsplit('.').next(),
Some("txt" | "csv" | "xml" | "yaml" | "yml")
)
{
ResourceType::TextAsset
} else if normalized.ends_with(".bytes") {
ResourceType::TableBundle
} else {
ResourceType::Other
}
@@ -210,6 +230,16 @@ impl AddressablesCatalogDriver {
return ResourceType::Manifest;
}
if resource_type_name.is_some_and(|name| name.contains("TextAsset")) {
return ResourceType::TextAsset;
}
if resource_type_name.is_some_and(|name| {
name.contains("AudioClip") || name.contains("VideoClip") || name.contains("Texture2D")
}) {
return ResourceType::Media;
}
Self::resource_type_for_path(path)
}
@@ -1017,4 +1047,24 @@ mod tests {
ResourceType::TableBundle
);
}
#[tokio::test]
async fn test_parse_text_and_media_resource_types() {
let driver = AddressablesCatalogDriver::new();
let catalog_json = r#"{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_Entries": [
{"internal_id": "TextAssets/dialogue.csv"},
{"internal_id": "MediaResources-Windows/voice/academy.acb"},
{"internal_id": "MediaResources-Windows/movie/opening.mp4"}
]
}"#;
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
assert_eq!(manifest.resources[0].resource_type, ResourceType::TextAsset);
assert_eq!(manifest.resources[1].resource_type, ResourceType::Media);
assert_eq!(manifest.resources[2].resource_type, ResourceType::Media);
}
}
+3 -3
View File
@@ -114,7 +114,7 @@ fn parse_obfuscated_game_main_config(bytes: &[u8]) -> Result<YostarJpGameMainCon
}
fn decrypt_inferred_ascii_json(bytes: &[u8]) -> Result<String, String> {
if bytes.len() % 2 != 0 {
if !bytes.len().is_multiple_of(2) {
return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string());
}
@@ -288,7 +288,7 @@ fn xor_bytes(bytes: &mut [u8], key: &[u8]) {
}
fn utf16le_to_string(bytes: &[u8]) -> Result<String, String> {
if bytes.len() % 2 != 0 {
if !bytes.len().is_multiple_of(2) {
return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into());
}
@@ -454,7 +454,7 @@ mod tests {
fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec<u8> {
let mut utf16 = utf16le_bytes(value);
xor_bytes(&mut utf16, &key);
xor_bytes(&mut utf16, key);
utf16
}
+2 -2
View File
@@ -965,8 +965,8 @@ mod tests {
};
let delta = current.diff(Some(&previous));
assert!(delta.connection_group_changed == false);
assert!(delta.app_version_changed == false);
assert!(!delta.connection_group_changed);
assert!(!delta.app_version_changed);
assert!(delta.bundle_version_changed);
assert!(delta.addressables_root_changed);
assert!(delta.root_token_changed);
+1 -1
View File
@@ -78,7 +78,7 @@ impl UnitySerializedFile {
)?);
}
let big_id_enabled = if version >= 11 && version < 14 {
let big_id_enabled = if (11..14).contains(&version) {
reader.read_i32("big_id_enabled")?
} else {
0