feat: add official resource sync pipeline

Add the production-facing official update service and bat-official-sync watch CLI for unattended resource synchronization.

Support launcher-resource discovery without installing the launcher, remote marker snapshots, local manifest audit and repair, official seed hash validation, bootstrap caching, richer Addressables coverage, SQLite resource persistence, and FFI JSON helpers.
This commit is contained in:
2026-07-05 23:49:56 +08:00
parent 99e66a48d7
commit 789402c887
35 changed files with 6262 additions and 177 deletions
+682 -3
View File
@@ -4,10 +4,14 @@
use super::driver::{GenericManifest, ManifestDriver, ManifestFormat, ManifestMetadata};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use bat_core::domain::{ResourceEntry, ResourceType};
use serde_json::Value;
use std::collections::HashMap;
const ADDRESSABLES_ENTRY_RECORD_INTS: usize = 7;
const PLATFORM_LOAD_PATH_PLACEHOLDER: &str = "{PlatformUtils.AddressableLoadPath}";
/// Addressables Catalog Driver
pub struct AddressablesCatalogDriver;
@@ -19,7 +23,14 @@ impl AddressablesCatalogDriver {
fn parse_json(raw_data: &[u8]) -> Result<Value, String> {
let text = std::str::from_utf8(raw_data).map_err(|e| format!("Invalid UTF-8: {}", e))?;
serde_json::from_str(text).map_err(|e| format!("Invalid JSON: {}", e))
serde_json::from_str(text).map_err(|e| {
format!(
"Invalid JSON at line {}, column {}: {}",
e.line(),
e.column(),
e
)
})
}
fn locator_id(json: &Value) -> Option<String> {
@@ -42,8 +53,143 @@ impl AddressablesCatalogDriver {
prefixes
}
fn blob_bytes(json: &Value, field: &str) -> Option<Vec<u8>> {
json.get(field)
.and_then(|value| value.as_str())
.and_then(|value| STANDARD.decode(value).ok())
}
fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
let slice = bytes.get(offset..end)?;
Some(u32::from_le_bytes(slice.try_into().ok()?))
}
fn read_i32_le(bytes: &[u8], offset: usize) -> Option<i32> {
let end = offset.checked_add(4)?;
let slice = bytes.get(offset..end)?;
Some(i32::from_le_bytes(slice.try_into().ok()?))
}
fn blob_count(bytes: &[u8]) -> Option<usize> {
Self::read_u32_le(bytes, 0).map(|value| value as usize)
}
fn blob_count_field(json: &Value, field: &str) -> Option<usize> {
Self::blob_bytes(json, field).and_then(|bytes| Self::blob_count(&bytes))
}
fn internal_id_count(json: &Value) -> Option<usize> {
json.get("m_InternalIds")
.and_then(|value| value.as_array())
.map(|values| values.len())
}
fn decode_serialized_string(bytes: &[u8], offset: usize) -> Option<(String, usize)> {
let (object, next_offset) = Self::read_serialized_object(bytes, offset)?;
object.into_key_string().map(|value| (value, next_offset))
}
fn read_serialized_object(bytes: &[u8], offset: usize) -> Option<(AddressablesObject, usize)> {
let object_type = *bytes.get(offset)?;
let mut cursor = offset.checked_add(1)?;
match object_type {
0 => {
let len = Self::read_u32_le(bytes, cursor)? as usize;
cursor = cursor.checked_add(4)?;
let end = cursor.checked_add(len)?;
let value = std::str::from_utf8(bytes.get(cursor..end)?)
.ok()?
.to_owned();
Some((AddressablesObject::String(value), end))
}
1 => {
let len = Self::read_u32_le(bytes, cursor)? as usize;
cursor = cursor.checked_add(4)?;
let end = cursor.checked_add(len)?;
let units = bytes
.get(cursor..end)?
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
let value = String::from_utf16(&units).ok()?;
Some((AddressablesObject::String(value), end))
}
2 => {
let end = cursor.checked_add(2)?;
let value = u16::from_le_bytes(bytes.get(cursor..end)?.try_into().ok()?);
Some((AddressablesObject::Unsigned(value as u32), end))
}
3 => {
let end = cursor.checked_add(4)?;
let value = u32::from_le_bytes(bytes.get(cursor..end)?.try_into().ok()?);
Some((AddressablesObject::Unsigned(value), end))
}
4 => {
let end = cursor.checked_add(4)?;
let value = i32::from_le_bytes(bytes.get(cursor..end)?.try_into().ok()?);
Some((AddressablesObject::Signed(value), end))
}
5 => {
let len = *bytes.get(cursor)? as usize;
cursor = cursor.checked_add(1)?;
let end = cursor.checked_add(len)?;
let value = std::str::from_utf8(bytes.get(cursor..end)?)
.ok()?
.to_owned();
Some((AddressablesObject::Hash128(value), end))
}
7 => Self::read_serialized_json_object(bytes, cursor),
_ => None,
}
}
fn read_serialized_json_object(
bytes: &[u8],
mut cursor: usize,
) -> Option<(AddressablesObject, usize)> {
let assembly_len = *bytes.get(cursor)? as usize;
cursor = cursor.checked_add(1)?;
let assembly_end = cursor.checked_add(assembly_len)?;
let assembly_name = std::str::from_utf8(bytes.get(cursor..assembly_end)?)
.ok()?
.to_owned();
cursor = assembly_end;
let class_len = *bytes.get(cursor)? as usize;
cursor = cursor.checked_add(1)?;
let class_end = cursor.checked_add(class_len)?;
let class_name = std::str::from_utf8(bytes.get(cursor..class_end)?)
.ok()?
.to_owned();
cursor = class_end;
let json_len = Self::read_u32_le(bytes, cursor)? as usize;
cursor = cursor.checked_add(4)?;
let json_end = cursor.checked_add(json_len)?;
let units = bytes
.get(cursor..json_end)?
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
let json_text = String::from_utf16(&units).ok()?;
let json = serde_json::from_str(&json_text).ok();
Some((
AddressablesObject::JsonObject {
assembly_name,
class_name,
json,
},
json_end,
))
}
fn resource_type_for_path(path: &str) -> ResourceType {
if path.ends_with(".bundle") {
if path.contains("TableBundles/") || path.ends_with(".bytes") {
ResourceType::TableBundle
} else if path.ends_with(".bundle") {
ResourceType::AssetBundle
} else if path.contains("catalog") {
ResourceType::Manifest
@@ -52,6 +198,21 @@ impl AddressablesCatalogDriver {
}
}
fn resource_type_for_compact_entry(
path: &str,
resource_type_name: Option<&str>,
) -> ResourceType {
if resource_type_name.is_some_and(|name| name.contains("IAssetBundleResource")) {
return ResourceType::AssetBundle;
}
if resource_type_name.is_some_and(|name| name.contains("ContentCatalogData")) {
return ResourceType::Manifest;
}
Self::resource_type_for_path(path)
}
fn resource_from_entry(value: &Value, index: usize) -> Option<ResourceEntry> {
let path = value
.get("internal_id")
@@ -64,9 +225,11 @@ impl AddressablesCatalogDriver {
return None;
}
let address = Self::string_field(value, &["address", "Address", "m_Address", "key", "Key"]);
let hash = value
.get("hash")
.or_else(|| value.get("Hash"))
.or_else(|| value.get("m_Hash"))
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("addressable_{}", index));
@@ -74,17 +237,85 @@ impl AddressablesCatalogDriver {
let size = value
.get("size")
.or_else(|| value.get("Size"))
.or_else(|| value.get("m_Size"))
.and_then(|value| value.as_u64())
.unwrap_or_default();
let dependencies = Self::dependencies_from_entry(value);
Some(ResourceEntry {
path: path.to_string(),
hash,
size,
resource_type: Self::resource_type_for_path(path),
address,
dependencies,
})
}
fn string_field(value: &Value, fields: &[&str]) -> Option<String> {
fields.iter().find_map(|field| {
value
.get(field)
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
})
}
fn string_array_field(value: &Value, fields: &[&str]) -> Vec<String> {
for field in fields {
if let Some(array) = value.get(field).and_then(|value| value.as_array()) {
let strings = array
.iter()
.filter_map(|item| match item {
Value::String(value) => Some(value.clone()),
Value::Object(object) => object
.get("internal_id")
.or_else(|| object.get("InternalId"))
.or_else(|| object.get("path"))
.or_else(|| object.get("Path"))
.and_then(|value| value.as_str())
.map(ToOwned::to_owned),
_ => None,
})
.collect::<Vec<_>>();
if !strings.is_empty() {
return strings;
}
}
}
Vec::new()
}
fn dependencies_from_entry(value: &Value) -> Vec<String> {
let dependencies = Self::string_array_field(
value,
&[
"dependencies",
"Dependencies",
"m_Dependencies",
"dependency",
"Dependency",
"m_Dependency",
],
);
if !dependencies.is_empty() {
return dependencies;
}
Self::string_array_field(
value,
&[
"m_Dependencies",
"asset_dependencies",
"AssetDependencies",
"resource_dependencies",
"ResourceDependencies",
],
)
}
fn entry_resources(json: &Value) -> Vec<ResourceEntry> {
json.get("m_Entries")
.or_else(|| json.get("entries"))
@@ -113,17 +344,314 @@ impl AddressablesCatalogDriver {
hash: format!("addressable_{}", index),
size: 0,
resource_type: Self::resource_type_for_path(path),
address: None,
dependencies: Vec::new(),
})
})
.collect()
}
fn key_data_resources(json: &Value) -> Vec<ResourceEntry> {
let Some(bytes) = Self::blob_bytes(json, "m_KeyDataString") else {
return Vec::new();
};
let expected_count = Self::blob_count(&bytes).unwrap_or_default();
if expected_count == 0 {
return Vec::new();
}
let limit = Self::internal_id_count(json).unwrap_or(expected_count);
let mut resources = Vec::with_capacity(expected_count.min(limit));
let mut offset = 4;
while offset < bytes.len() && resources.len() < limit {
let Some((path, next_offset)) = Self::decode_serialized_string(&bytes, offset) else {
break;
};
offset = next_offset;
if path.is_empty() {
continue;
}
let index = resources.len();
let resource_type = Self::resource_type_for_path(&path);
resources.push(ResourceEntry {
path,
hash: format!("addressable_{}", index),
size: 0,
resource_type,
address: None,
dependencies: Vec::new(),
});
}
resources
}
fn compact_entry_resources(json: &Value) -> Vec<ResourceEntry> {
let Some(internal_ids) = Self::string_array(json, "m_InternalIds") else {
return Vec::new();
};
let Some(provider_ids) = Self::string_array(json, "m_ProviderIds") else {
return Vec::new();
};
let Some(key_bytes) = Self::blob_bytes(json, "m_KeyDataString") else {
return Vec::new();
};
let Some(entry_records) = Self::compact_entry_records(json) else {
return Vec::new();
};
let Some(buckets) = Self::compact_buckets(json) else {
return Vec::new();
};
let keys = Self::compact_keys(&key_bytes, &buckets);
if keys.is_empty() {
return Vec::new();
}
let internal_id_prefixes = Self::string_array(json, "m_InternalIdPrefixes")
.unwrap_or_default()
.into_iter()
.collect::<Vec<_>>();
let extra_data = Self::blob_bytes(json, "m_ExtraDataString").unwrap_or_default();
entry_records
.iter()
.enumerate()
.filter_map(|(index, record)| {
let internal_id = internal_ids.get(record.internal_id as usize)?;
provider_ids.get(record.provider_index as usize)?;
let primary_key = keys
.get(record.primary_key_index as usize)
.and_then(|key| key.as_ref())
.and_then(AddressablesObject::key_string)
.unwrap_or_else(|| format!("addressable_{}", index));
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
let extra = Self::extra_data_at(&extra_data, record.data_index);
let resource_type_name = Self::resource_type_name(json, record.resource_type_index);
let dependencies =
Self::compact_dependencies(record, &entry_records, &buckets, &keys);
let hash = extra
.hash
.filter(|value| !value.is_empty())
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
.unwrap_or_else(|| format!("addressable_{}", index));
Some(ResourceEntry {
path: path.clone(),
hash,
size: extra.bundle_size.unwrap_or_default(),
resource_type: Self::resource_type_for_compact_entry(
&path,
resource_type_name.as_deref(),
),
address: if primary_key.is_empty() {
None
} else {
Some(primary_key)
},
dependencies,
})
})
.collect()
}
fn string_array(json: &Value, field: &str) -> Option<Vec<String>> {
json.get(field)?
.as_array()?
.iter()
.map(|value| value.as_str().map(ToOwned::to_owned))
.collect()
}
fn compact_entry_records(json: &Value) -> Option<Vec<CompactEntryRecord>> {
let bytes = Self::blob_bytes(json, "m_EntryDataString")?;
let count = Self::blob_count(&bytes)?;
let expected_len = 4usize.checked_add(
count
.checked_mul(ADDRESSABLES_ENTRY_RECORD_INTS)?
.checked_mul(4)?,
)?;
if bytes.len() < expected_len {
return None;
}
let mut records = Vec::with_capacity(count);
let mut offset = 4;
for _ in 0..count {
records.push(CompactEntryRecord {
internal_id: Self::read_i32_le(&bytes, offset)?,
provider_index: Self::read_i32_le(&bytes, offset + 4)?,
dependency_key_index: Self::read_i32_le(&bytes, offset + 8)?,
data_index: Self::read_i32_le(&bytes, offset + 16)?,
primary_key_index: Self::read_i32_le(&bytes, offset + 20)?,
resource_type_index: Self::read_i32_le(&bytes, offset + 24)?,
});
offset += ADDRESSABLES_ENTRY_RECORD_INTS * 4;
}
Some(records)
}
fn compact_buckets(json: &Value) -> Option<Vec<CompactBucket>> {
let bytes = Self::blob_bytes(json, "m_BucketDataString")?;
let count = Self::blob_count(&bytes)?;
let mut buckets = Vec::with_capacity(count);
let mut offset = 4;
for _ in 0..count {
let key_offset = Self::read_i32_le(&bytes, offset)?;
let entry_count = Self::read_i32_le(&bytes, offset + 4)?;
if key_offset < 0 || entry_count < 0 {
return None;
}
offset += 8;
let mut entries = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
let entry_index = Self::read_i32_le(&bytes, offset)?;
if entry_index < 0 {
return None;
}
entries.push(entry_index as usize);
offset += 4;
}
buckets.push(CompactBucket {
key_offset: key_offset as usize,
entries,
});
}
Some(buckets)
}
fn compact_keys(
key_bytes: &[u8],
buckets: &[CompactBucket],
) -> Vec<Option<AddressablesObject>> {
buckets
.iter()
.map(|bucket| {
Self::read_serialized_object(key_bytes, bucket.key_offset).map(|(object, _)| object)
})
.collect()
}
fn compact_dependencies(
record: &CompactEntryRecord,
records: &[CompactEntryRecord],
buckets: &[CompactBucket],
keys: &[Option<AddressablesObject>],
) -> Vec<String> {
if record.dependency_key_index < 0 {
return Vec::new();
}
buckets
.get(record.dependency_key_index as usize)
.into_iter()
.flat_map(|bucket| bucket.entries.iter().copied())
.filter_map(|entry_index| records.get(entry_index))
.filter_map(|dependency_record| keys.get(dependency_record.primary_key_index as usize))
.filter_map(|key| key.as_ref())
.filter_map(AddressablesObject::key_string)
.collect()
}
fn extra_data_at(extra_data: &[u8], data_index: i32) -> AddressablesExtraData {
if data_index < 0 {
return AddressablesExtraData::default();
}
let Some((object, _)) = Self::read_serialized_object(extra_data, data_index as usize)
else {
return AddressablesExtraData::default();
};
let AddressablesObject::JsonObject { json, .. } = object else {
return AddressablesExtraData::default();
};
let Some(json) = json else {
return AddressablesExtraData::default();
};
AddressablesExtraData {
hash: json
.get("m_Hash")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned),
bundle_name: json
.get("m_BundleName")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned),
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
}
}
fn resource_type_name(json: &Value, index: i32) -> Option<String> {
if index < 0 {
return None;
}
json.get("m_resourceTypes")?
.as_array()?
.get(index as usize)?
.get("m_ClassName")?
.as_str()
.map(ToOwned::to_owned)
}
fn normalize_internal_id(prefixes: &[String], internal_id: &str) -> String {
let expanded = Self::expand_internal_id(prefixes, internal_id);
let normalized = expanded.replace('\\', "/");
normalized
.strip_prefix(PLATFORM_LOAD_PATH_PLACEHOLDER)
.map(|path| path.trim_start_matches('/').to_string())
.unwrap_or(normalized)
}
fn expand_internal_id(prefixes: &[String], internal_id: &str) -> String {
if prefixes.is_empty() {
return internal_id.to_string();
}
let Some((index, path)) = internal_id.rsplit_once('#') else {
return internal_id.to_string();
};
let Ok(index) = index.parse::<usize>() else {
return internal_id.to_string();
};
let Some(prefix) = prefixes.get(index) else {
return internal_id.to_string();
};
format!("{prefix}{path}")
}
fn resources(json: &Value) -> Vec<ResourceEntry> {
let entry_resources = Self::entry_resources(json);
if !entry_resources.is_empty() {
return entry_resources;
}
let compact_resources = Self::compact_entry_resources(json);
if !compact_resources.is_empty() {
return compact_resources;
}
let key_resources = Self::key_data_resources(json);
if !key_resources.is_empty()
&& Self::internal_id_count(json)
.map(|count| key_resources.len() >= count)
.unwrap_or(true)
{
return key_resources;
}
Self::internal_id_resources(json)
}
@@ -131,13 +659,35 @@ impl AddressablesCatalogDriver {
let mut extra = HashMap::new();
Self::insert_array_len(&mut extra, json, "m_InternalIds", "internal_id_count");
Self::insert_array_len(&mut extra, json, "m_Entries", "entry_count");
Self::insert_array_len(&mut extra, json, "m_resourceTypes", "resource_type_count");
Self::insert_blob_count(&mut extra, json, "m_KeyDataString", "key_object_count");
Self::insert_blob_count(
&mut extra,
json,
"m_BucketDataString",
"bucket_record_count",
);
Self::insert_blob_count(&mut extra, json, "m_EntryDataString", "entry_record_count");
Self::insert_string_len(&mut extra, json, "m_KeyDataString", "key_data_string_len");
Self::insert_string_len(
&mut extra,
json,
"m_BucketDataString",
"bucket_data_string_len",
);
Self::insert_string_len(
&mut extra,
json,
"m_EntryDataString",
"entry_data_string_len",
);
Self::insert_string_len(
&mut extra,
json,
"m_ExtraDataString",
"extra_data_string_len",
);
Self::insert_dependency_count(&mut extra, json);
extra
}
@@ -165,6 +715,82 @@ impl AddressablesCatalogDriver {
extra.insert(key.to_string(), len.to_string());
}
}
fn insert_blob_count(
extra: &mut HashMap<String, String>,
json: &Value,
field: &str,
key: &str,
) {
if let Some(count) = Self::blob_count_field(json, field) {
extra.insert(key.to_string(), count.to_string());
}
}
fn insert_dependency_count(extra: &mut HashMap<String, String>, json: &Value) {
let count = Self::entry_resources(json)
.into_iter()
.map(|entry| entry.dependencies.len())
.sum::<usize>();
if count > 0 {
extra.insert("dependency_count".to_string(), count.to_string());
}
}
}
#[derive(Debug, Clone)]
enum AddressablesObject {
String(String),
Unsigned(u32),
Signed(i32),
Hash128(String),
JsonObject {
assembly_name: String,
class_name: String,
json: Option<Value>,
},
}
impl AddressablesObject {
fn key_string(&self) -> Option<String> {
match self {
Self::String(value) | Self::Hash128(value) => Some(value.clone()),
Self::Unsigned(value) => Some(value.to_string()),
Self::Signed(value) => Some(value.to_string()),
Self::JsonObject {
assembly_name,
class_name,
..
} => Some(format!("{assembly_name}:{class_name}")),
}
}
fn into_key_string(self) -> Option<String> {
self.key_string()
}
}
#[derive(Debug, Clone)]
struct CompactEntryRecord {
internal_id: i32,
provider_index: i32,
dependency_key_index: i32,
data_index: i32,
primary_key_index: i32,
resource_type_index: i32,
}
#[derive(Debug, Clone)]
struct CompactBucket {
key_offset: usize,
entries: Vec<usize>,
}
#[derive(Debug, Clone, Default)]
struct AddressablesExtraData {
hash: Option<String>,
bundle_name: Option<String>,
bundle_size: Option<u64>,
}
impl Default for AddressablesCatalogDriver {
@@ -292,11 +918,14 @@ mod tests {
"m_InternalIds": ["synthetic/fallback.bundle"],
"m_KeyDataString": "key-data",
"m_EntryDataString": "entry-data",
"m_resourceTypes": ["AssetBundle", "Manifest"],
"m_Entries": [
{
"internal_id": "synthetic/minimal.bundle",
"hash": "synthetic-entry-hash",
"size": 119
"size": 119,
"address": "Character_001",
"dependencies": ["synthetic/shared.bundle"]
},
{
"internal_id": "synthetic/catalog.json",
@@ -312,6 +941,14 @@ mod tests {
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
assert_eq!(manifest.resources[0].size, 119);
assert_eq!(
manifest.resources[0].address.as_deref(),
Some("Character_001")
);
assert_eq!(
manifest.resources[0].dependencies,
vec!["synthetic/shared.bundle".to_string()]
);
assert_eq!(
manifest.resources[0].resource_type,
ResourceType::AssetBundle
@@ -337,5 +974,47 @@ mod tests {
manifest.metadata.extra.get("entry_data_string_len"),
Some(&"10".to_string())
);
assert_eq!(
manifest.metadata.extra.get("resource_type_count"),
Some(&"2".to_string())
);
assert_eq!(
manifest.metadata.extra.get("dependency_count"),
Some(&"1".to_string())
);
}
#[tokio::test]
async fn test_parse_reports_json_location() {
let driver = AddressablesCatalogDriver::new();
let error = driver
.parse(
b"{\n \"m_LocatorId\": \"AddressablesMainContentCatalog\",\n \"m_Entries\": [\n",
)
.await
.expect_err("expected parse failure");
assert!(error.contains("Invalid JSON at line"));
}
#[tokio::test]
async fn test_parse_table_bundle_resource_types() {
let driver = AddressablesCatalogDriver::new();
let catalog_json = r#"{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIds": [
"TableBundles/10031865119468584059_717066257.bytes"
]
}"#;
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
assert_eq!(manifest.resources.len(), 1);
assert_eq!(
manifest.resources[0].resource_type,
ResourceType::TableBundle
);
}
}
+3 -5
View File
@@ -42,11 +42,9 @@ impl YostarJpGameMainConfig {
}
fn from_serialized_file(serialized: &UnitySerializedFile) -> Result<Self, String> {
let asset = serialized
.text_asset("GameMainConfig")
.ok_or_else(|| {
"GameMainConfig text asset not found in Unity serialized file".to_string()
})?;
let asset = serialized.text_asset("GameMainConfig").ok_or_else(|| {
"GameMainConfig text asset not found in Unity serialized file".to_string()
})?;
Self::from_encrypted_bytes(&asset.bytes)
}
+2 -2
View File
@@ -4,15 +4,15 @@
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
pub mod inventory;
pub mod game_main_config;
pub mod inventory;
pub mod launcher;
pub mod yostar_jp;
pub use game_main_config::YostarJpGameMainConfig;
pub use inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
pub use game_main_config::YostarJpGameMainConfig;
pub use launcher::{
YostarJpInstalledGameBootstrap, YostarJpLauncherConfig, YostarJpLauncherManifest,
};
+8 -8
View File
@@ -8,7 +8,7 @@
//! naked `.bundle` names present in `catalog_Remote.json`
//! - Android uses its own patch-pack directory
use serde::Deserialize;
use serde::{Deserialize, Serialize};
/// Official JP server-info host.
pub const SERVER_INFO_HOST: &str = "yostar-serverinfo.bluearchiveyostar.com";
@@ -230,7 +230,7 @@ impl YostarJpConnectionGroup {
}
/// Patch platform selected by the game client.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum PatchPlatform {
/// Standalone Windows client.
Windows,
@@ -275,7 +275,7 @@ pub fn verified_official_platforms() -> Vec<PatchPlatform> {
}
/// Kind of official JP resource endpoint discovered from a resource root.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum YostarJpResourceEndpointKind {
/// `TableBundles/TableCatalog.bytes`.
TableCatalog,
@@ -296,7 +296,7 @@ pub enum YostarJpResourceEndpointKind {
}
/// One official JP resource URL seed.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct YostarJpResourceEndpoint {
/// Endpoint kind.
pub kind: YostarJpResourceEndpointKind,
@@ -307,7 +307,7 @@ pub struct YostarJpResourceEndpoint {
}
/// Official JP resource discovery seed plan.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct YostarJpResourceDiscoveryPlan {
/// Selected server-info connection group name.
pub connection_group_name: String,
@@ -322,7 +322,7 @@ pub struct YostarJpResourceDiscoveryPlan {
}
/// Snapshot of the official JP resource state observed at a point in time.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct YostarJpSyncSnapshot {
/// Selected server-info connection group name.
pub connection_group_name: String,
@@ -368,7 +368,7 @@ impl YostarJpSyncSnapshot {
}
/// Delta between an official snapshot and a previously stored snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct YostarJpSyncDelta {
/// Whether this is the first observation.
pub is_initial: bool,
@@ -410,7 +410,7 @@ impl YostarJpSyncDelta {
}
/// Individual endpoint difference.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum YostarJpResourceEndpointChange {
/// Endpoint newly observed.
Added(YostarJpResourceEndpoint),
+9 -12
View File
@@ -218,7 +218,10 @@ fn read_serialized_type(
if version >= 12 || version == 10 {
let node_count = reader.read_i32("type_tree_node_count")?;
if node_count < 0 {
return Err(format!("Invalid Unity type tree node count: {}", node_count));
return Err(format!(
"Invalid Unity type tree node count: {}",
node_count
));
}
let string_buffer_size = reader.read_i32("type_tree_string_buffer_size")?;
if string_buffer_size < 0 {
@@ -229,10 +232,7 @@ fn read_serialized_type(
}
let node_size = 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + if version >= 19 { 8 } else { 0 };
reader.read_bytes(
node_count as usize * node_size,
"type_tree_nodes",
)?;
reader.read_bytes(node_count as usize * node_size, "type_tree_nodes")?;
reader.read_bytes(string_buffer_size as usize, "type_tree_strings")?;
}
@@ -244,10 +244,7 @@ fn read_serialized_type(
dependency_count
));
}
reader.read_bytes(
dependency_count as usize * 4,
"type_tree_dependencies",
)?;
reader.read_bytes(dependency_count as usize * 4, "type_tree_dependencies")?;
}
}
@@ -523,9 +520,9 @@ mod tests {
assert_eq!(
&asset.bytes[..32],
&[
0x60, 0x23, 0x0a, 0x06, 0x95, 0x72, 0x83, 0x57, 0x54, 0x23, 0x49, 0x06,
0xfc, 0x72, 0xb4, 0x57, 0x57, 0x23, 0x6b, 0x06, 0x98, 0x72, 0xb5, 0x57,
0x71, 0x23, 0x1b, 0x06, 0xec, 0x72, 0xf6, 0x57,
0x60, 0x23, 0x0a, 0x06, 0x95, 0x72, 0x83, 0x57, 0x54, 0x23, 0x49, 0x06, 0xfc, 0x72,
0xb4, 0x57, 0x57, 0x23, 0x6b, 0x06, 0x98, 0x72, 0xb5, 0x57, 0x71, 0x23, 0x1b, 0x06,
0xec, 0x72, 0xf6, 0x57,
],
);
}
+22
View File
@@ -0,0 +1,22 @@
{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": [],
"m_ProviderIds": [
"UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider"
],
"m_InternalIds": [
"{PlatformUtils.AddressableLoadPath}\\academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
"{PlatformUtils.AddressableLoadPath}\\academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
"{PlatformUtils.AddressableLoadPath}\\shared_assets_all_123.bundle"
],
"m_resourceTypes": [
{
"m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
"m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.IAssetBundleResource"
}
],
"m_KeyDataString": "BAAAAAA+AAAAYWNhZGVteS1fbXhsb2FkLXByZWZhYnMtMjAyNS0wNy0wMl9hc3NldHNfYWxsXzYzODk4MTA2OS5idW5kbGUAPwAAAGFjYWRlbXktX214bG9hZC1wcmVmYWJzLTIwMjUtMDgtMjZfYXNzZXRzX2FsbF8xNTgxMzUyOTM1LmJ1bmRsZQAcAAAAc2hhcmVkX2Fzc2V0c19hbGxfMTIzLmJ1bmRsZQAQAAAAZGVwZW5kZW5jeS1zZXQtMA==",
"m_BucketDataString": "BAAAAAQAAAABAAAAAAAAAEcAAAABAAAAAQAAAIsAAAABAAAAAgAAAKwAAAABAAAAAgAAAA==",
"m_EntryDataString": "AwAAAAAAAAAAAAAAAwAAAHsAAAAAAAAAAAAAAAAAAAABAAAAAAAAAP////8AAAAARQEAAAEAAAAAAAAAAgAAAAAAAAD/////AAAAAJACAAACAAAAAAAAAA==",
"m_ExtraDataString": "B0xVbml0eS5SZXNvdXJjZU1hbmFnZXIsIFZlcnNpb249MC4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj1udWxsSlVuaXR5RW5naW5lLlJlc291cmNlTWFuYWdlbWVudC5SZXNvdXJjZVByb3ZpZGVycy5Bc3NldEJ1bmRsZVJlcXVlc3RPcHRpb25zqAAAAHsAIgBtAF8ASABhAHMAaAAiADoAIgBoAGEAcwBoAC0AbQBhAGkAbgAiACwAIgBtAF8AQwByAGMAIgA6ADAALAAiAG0AXwBCAHUAbgBkAGwAZQBOAGEAbQBlACIAOgAiAGIAdQBuAGQAbABlAC0AbQBhAGkAbgAiACwAIgBtAF8AQgB1AG4AZABsAGUAUwBpAHoAZQAiADoAMQA5ADMAMgA4ADcAMgB9AAdMVW5pdHkuUmVzb3VyY2VNYW5hZ2VyLCBWZXJzaW9uPTAuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49bnVsbEpVbml0eUVuZ2luZS5SZXNvdXJjZU1hbmFnZW1lbnQuUmVzb3VyY2VQcm92aWRlcnMuQXNzZXRCdW5kbGVSZXF1ZXN0T3B0aW9uc64AAAB7ACIAbQBfAEgAYQBzAGgAIgA6ACIAaABhAHMAaAAtAHMAZQBjAG8AbgBkACIALAAiAG0AXwBDAHIAYwAiADoAMAAsACIAbQBfAEIAdQBuAGQAbABlAE4AYQBtAGUAIgA6ACIAYgB1AG4AZABsAGUALQBzAGUAYwBvAG4AZAAiACwAIgBtAF8AQgB1AG4AZABsAGUAUwBpAHoAZQAiADoAMQA2ADIAMQAzADQAfQAHTFVuaXR5LlJlc291cmNlTWFuYWdlciwgVmVyc2lvbj0wLjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPW51bGxKVW5pdHlFbmdpbmUuUmVzb3VyY2VNYW5hZ2VtZW50LlJlc291cmNlUHJvdmlkZXJzLkFzc2V0QnVuZGxlUmVxdWVzdE9wdGlvbnOuAAAAewAiAG0AXwBIAGEAcwBoACIAOgAiAGgAYQBzAGgALQBzAGgAYQByAGUAZAAiACwAIgBtAF8AQwByAGMAIgA6ADAALAAiAG0AXwBCAHUAbgBkAGwAZQBOAGEAbQBlACIAOgAiAGIAdQBuAGQAbABlAC0AcwBoAGEAcgBlAGQAIgAsACIAbQBfAEIAdQBuAGQAbABlAFMAaQB6AGUAIgA6ADEANQAzADQAOAAwAH0A"
}
@@ -0,0 +1,45 @@
{
"format": "AddressablesCatalog",
"locator_id": "AddressablesMainContentCatalog",
"cdn_prefixes": [],
"resource_count": 3,
"resources": [
{
"path": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
"hash": "hash-main",
"size": 1932872,
"resource_type": "AssetBundle",
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
"dependencies": [
"shared_assets_all_123.bundle"
]
},
{
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
"hash": "hash-second",
"size": 162134,
"resource_type": "AssetBundle",
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
"dependencies": []
},
{
"path": "shared_assets_all_123.bundle",
"hash": "hash-shared",
"size": 153480,
"resource_type": "AssetBundle",
"address": "shared_assets_all_123.bundle",
"dependencies": []
}
],
"metadata": {
"internal_id_count": "3",
"resource_type_count": "1",
"key_object_count": "4",
"bucket_record_count": "4",
"entry_record_count": "3",
"key_data_string_len": "260",
"bucket_data_string_len": "72",
"entry_data_string_len": "120",
"extra_data_string_len": "1316"
}
}
@@ -0,0 +1,34 @@
use bat_adapters::manifest::ManifestDriverRegistry;
use serde_json::json;
#[tokio::test]
async fn parses_real_shape_addressables_catalog_against_golden() {
let catalog = include_str!("fixtures/real_addressables/catalog.json");
let manifest = ManifestDriverRegistry::with_defaults()
.parse(catalog.as_bytes())
.await
.expect("parse real-shape Addressables catalog");
let actual = json!({
"format": format!("{:?}", manifest.format),
"locator_id": manifest.metadata.locator_id,
"cdn_prefixes": manifest.metadata.cdn_prefixes,
"resource_count": manifest.resources.len(),
"resources": manifest.resources.iter().map(|resource| {
json!({
"path": resource.path,
"hash": resource.hash,
"size": resource.size,
"resource_type": format!("{:?}", resource.resource_type),
"address": resource.address,
"dependencies": resource.dependencies,
})
}).collect::<Vec<_>>(),
"metadata": manifest.metadata.extra,
});
let expected: serde_json::Value =
serde_json::from_str(include_str!("golden/real_addressables.json")).unwrap();
assert_eq!(actual, expected);
}
@@ -18,4 +18,7 @@ async fn parses_local_real_addressables_catalog() {
);
assert!(manifest.resources.len() > 1_000);
assert!(manifest.metadata.extra.contains_key("internal_id_count"));
assert!(manifest.metadata.extra.contains_key("key_object_count"));
assert!(manifest.metadata.extra.contains_key("bucket_record_count"));
assert!(manifest.metadata.extra.contains_key("entry_record_count"));
}