mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:46:44 +08:00
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.
681 lines
22 KiB
Rust
681 lines
22 KiB
Rust
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
|
use bat_adapters::official::inventory::{
|
|
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
|
|
};
|
|
use bat_adapters::official::yostar_jp::{
|
|
is_official_yostar_jp_url, verified_official_platforms, PatchPlatform,
|
|
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
|
|
};
|
|
use bat_infrastructure::{
|
|
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
|
|
OfficialResourcePullService,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
// Synthetic fixture values for an official-only integration test.
|
|
const TEST_LAUNCHER_VERSION: &str = "1.7.2-fixture";
|
|
const TEST_LAUNCHER_LATEST_VERSION: &str = "1.70.0-fixture";
|
|
const TEST_LAUNCHER_LATEST_FILE_PATH: &str =
|
|
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-launcher-latest.zip";
|
|
const TEST_LAUNCHER_MANIFEST_URL: &str =
|
|
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json";
|
|
const TEST_LAUNCHER_GAME_CONFIG_JSON_URL: &str =
|
|
"https://api-launcher-jp.yo-star.com/api/launcher/game/config/json?version=1.70.0-fixture&file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-launcher-latest.zip";
|
|
const TEST_LAUNCHER_MANIFEST_SOURCE: &str =
|
|
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
|
|
const TEST_LAUNCHER_GAME_ZIP_URL: &str =
|
|
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
|
|
const TEST_SERVER_INFO_URL: &str =
|
|
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json";
|
|
const TEST_CONNECTION_GROUP: &str = "Fixture-Audit";
|
|
const TEST_ADDRESSABLES_ROOT: &str =
|
|
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55";
|
|
|
|
#[test]
|
|
fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
|
|
let harness = TestHarness::new();
|
|
let bootstrap = harness.fetch_bootstrap();
|
|
|
|
assert_eq!(
|
|
bootstrap.game_config.game_latest_version,
|
|
TEST_LAUNCHER_LATEST_VERSION
|
|
);
|
|
assert_eq!(
|
|
bootstrap.game_config.game_latest_file_path,
|
|
TEST_LAUNCHER_LATEST_FILE_PATH
|
|
);
|
|
assert_eq!(bootstrap.manifest_url, TEST_LAUNCHER_MANIFEST_URL);
|
|
assert_eq!(
|
|
bootstrap.manifest_source.as_deref(),
|
|
Some(TEST_LAUNCHER_MANIFEST_SOURCE)
|
|
);
|
|
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL);
|
|
assert_eq!(bootstrap.manifest_file_count, 1);
|
|
assert_eq!(
|
|
bootstrap.game_main_config.server_info_data_url.as_deref(),
|
|
Some(TEST_SERVER_INFO_URL)
|
|
);
|
|
assert_eq!(
|
|
bootstrap
|
|
.game_main_config
|
|
.default_connection_group
|
|
.as_deref(),
|
|
Some(TEST_CONNECTION_GROUP)
|
|
);
|
|
assert_ne!(
|
|
bootstrap.game_zip_url,
|
|
format!(
|
|
"https://launcher-pkg-ba-jp.yo-star.com/{}",
|
|
bootstrap.game_config.game_latest_file_path
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_bootstrap_falls_back_to_latest_file_path_when_manifest_source_is_not_zip() {
|
|
let harness = TestHarness::new_with_manifest_source("/BlueArchive_JP-1.70.436321-game");
|
|
let bootstrap = harness.fetch_bootstrap();
|
|
|
|
assert_eq!(
|
|
bootstrap.manifest_source.as_deref(),
|
|
Some("/BlueArchive_JP-1.70.436321-game")
|
|
);
|
|
assert_eq!(
|
|
bootstrap.game_zip_url,
|
|
format!(
|
|
"https://launcher-pkg-ba-jp.yo-star.com/{}",
|
|
TEST_LAUNCHER_LATEST_FILE_PATH
|
|
)
|
|
);
|
|
|
|
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
|
|
assert!(curl_log.contains(TEST_LAUNCHER_LATEST_FILE_PATH));
|
|
assert!(!curl_log.contains(" /BlueArchive_JP-1.70.436321-game"));
|
|
}
|
|
|
|
#[test]
|
|
fn official_pipeline_can_dry_run_and_pull_without_local_client() {
|
|
let harness = TestHarness::new();
|
|
let bootstrap = harness.fetch_bootstrap();
|
|
let fetcher = harness.fetcher();
|
|
|
|
let server_info_url = bootstrap
|
|
.game_main_config
|
|
.server_info_data_url
|
|
.as_ref()
|
|
.unwrap();
|
|
assert!(is_official_yostar_jp_url(server_info_url));
|
|
|
|
let server_info_bytes = fetcher.fetch_bytes(server_info_url).unwrap();
|
|
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap();
|
|
let discovery = server_info
|
|
.discovery_plan(
|
|
bootstrap
|
|
.game_main_config
|
|
.default_connection_group
|
|
.as_deref()
|
|
.unwrap(),
|
|
&bootstrap.game_config.game_latest_version,
|
|
&verified_official_platforms(),
|
|
)
|
|
.unwrap();
|
|
|
|
let inventory = fetch_platform_inventory(&fetcher, &discovery);
|
|
let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory);
|
|
let all_urls = plan.all_urls().unwrap();
|
|
|
|
assert_eq!(plan.platforms, verified_official_platforms());
|
|
assert_eq!(all_urls.len(), 19);
|
|
assert!(all_urls.iter().all(|url| is_official_yostar_jp_url(url)));
|
|
assert!(all_urls.iter().all(|url| !url.contains("bluearchive.cafe")));
|
|
assert!(all_urls
|
|
.iter()
|
|
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
|
|
assert!(all_urls
|
|
.iter()
|
|
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip")));
|
|
assert!(all_urls
|
|
.iter()
|
|
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
|
|
|
|
let report = harness.pull_service().pull(&plan).unwrap();
|
|
assert_eq!(report.items.len(), all_urls.len());
|
|
assert!(report.total_bytes() > 0);
|
|
assert!(report.items.iter().all(|item| item.destination.exists()));
|
|
|
|
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
|
|
assert!(curl_log.contains(server_info_url));
|
|
assert!(!curl_log.contains("bluearchive.cafe"));
|
|
}
|
|
|
|
struct TestHarness {
|
|
temp: TempDir,
|
|
curl_script: std::path::PathBuf,
|
|
unzip_script: std::path::PathBuf,
|
|
curl_log: std::path::PathBuf,
|
|
}
|
|
|
|
impl TestHarness {
|
|
fn new() -> Self {
|
|
Self::new_with_manifest_source(TEST_LAUNCHER_MANIFEST_SOURCE)
|
|
}
|
|
|
|
fn new_with_manifest_source(manifest_source: &str) -> Self {
|
|
let temp = TempDir::new().unwrap();
|
|
let resources_assets = synthetic_resources_assets();
|
|
|
|
let resources_assets_path = temp.path().join("resources.assets");
|
|
fs::write(&resources_assets_path, &resources_assets).unwrap();
|
|
|
|
let zip_fixture = temp.path().join("downloaded.zip");
|
|
fs::write(&zip_fixture, b"dummy zip content").unwrap();
|
|
|
|
let curl_log = temp.path().join("curl.log");
|
|
let curl_script = temp.path().join("fake-curl");
|
|
write_executable(
|
|
&curl_script,
|
|
&official_curl_script(&curl_log, &zip_fixture, manifest_source),
|
|
);
|
|
|
|
let unzip_script = temp.path().join("fake-unzip");
|
|
write_executable(
|
|
&unzip_script,
|
|
&launcher_unzip_script(&resources_assets_path),
|
|
);
|
|
|
|
Self {
|
|
temp,
|
|
curl_script,
|
|
unzip_script,
|
|
curl_log,
|
|
}
|
|
}
|
|
|
|
fn fetch_bootstrap(
|
|
&self,
|
|
) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap {
|
|
OfficialGameMainConfigBootstrapService::with_commands(
|
|
TEST_LAUNCHER_VERSION,
|
|
&self.curl_script,
|
|
&self.unzip_script,
|
|
)
|
|
.fetch_bootstrap()
|
|
.unwrap()
|
|
}
|
|
|
|
fn fetcher(&self) -> OfficialResourcePullService {
|
|
OfficialResourcePullService::with_curl_command(
|
|
self.temp.path().join("official-pull"),
|
|
&self.curl_script,
|
|
)
|
|
}
|
|
|
|
fn pull_service(&self) -> OfficialResourcePullService {
|
|
OfficialResourcePullService::with_curl_command(
|
|
self.temp.path().join("pulled"),
|
|
&self.curl_script,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn write_executable(path: &Path, content: &str) {
|
|
fs::write(path, content).unwrap();
|
|
let mut permissions = fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
|
|
fn official_curl_script(curl_log: &Path, zip_fixture: &Path, manifest_source: &str) -> String {
|
|
format!(
|
|
r#"#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
log={}
|
|
printf '%s\n' "$*" >> "$log"
|
|
output=""
|
|
url=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--output)
|
|
output="$2"
|
|
shift 2
|
|
;;
|
|
--url)
|
|
url="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
emit_text() {{
|
|
local payload="$1"
|
|
if [[ -n "$output" ]]; then
|
|
mkdir -p "$(dirname "$output")"
|
|
printf '%s' "$payload" > "$output"
|
|
else
|
|
printf '%s' "$payload"
|
|
fi
|
|
}}
|
|
|
|
emit_zip_fixture() {{
|
|
mkdir -p "$(dirname "$output")"
|
|
cp {} "$output"
|
|
}}
|
|
|
|
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}"}}}}
|
|
JSON
|
|
elif [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/advanced/game/download/cdn" ]]; then
|
|
cat <<'JSON'
|
|
{{"code":200,"message":"ok","data":{{"primary_cdn":"https://launcher-pkg-ba-jp.yo-star.com","back_up_cdn":"https://launcher-pkg-ba-jp-bk.yo-star.com"}}}}
|
|
JSON
|
|
elif [[ "$url" == "{launcher_game_config_json_url}" ]]; then
|
|
cat <<'JSON'
|
|
{{"code":200,"message":"ok","data":{{"url":"{launcher_manifest_url}"}}}}
|
|
JSON
|
|
elif [[ "$url" == "{launcher_manifest_url}" ]]; then
|
|
cat <<'JSON'
|
|
{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}}]}}
|
|
JSON
|
|
elif [[ "$url" == "{server_info_url}" ]]; then
|
|
cat <<'JSON'
|
|
{{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}}
|
|
JSON
|
|
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/catalog_StandaloneWindows64.zip" ]]; then
|
|
emit_zip_fixture
|
|
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/catalog_StandaloneWindows64.hash" ]]; then
|
|
emit_text "catalog-windows-hash"
|
|
elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.zip" ]]; then
|
|
emit_zip_fixture
|
|
elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]]; then
|
|
emit_text "catalog-android-hash"
|
|
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.bytes" ]]; then
|
|
emit_text "ExcelDB.db"
|
|
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.hash" ]]; then
|
|
emit_text "{table_catalog_hash}"
|
|
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes" ]]; then
|
|
emit_text "FullPatch_000.zip"
|
|
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.hash" ]]; then
|
|
emit_text "{windows_bundle_catalog_hash}"
|
|
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.bytes" ]]; then
|
|
emit_text "JP_Airi_Win.zip"
|
|
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.hash" ]]; then
|
|
emit_text "{windows_media_catalog_hash}"
|
|
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then
|
|
emit_text "FullPatch_001.zip"
|
|
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then
|
|
emit_text "{android_bundle_catalog_hash}"
|
|
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes" ]]; then
|
|
emit_text "JP_Airi_Android.zip"
|
|
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
|
|
emit_text "{android_media_catalog_hash}"
|
|
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
|
|
filename="${{url##*/}}"
|
|
if [[ "$filename" == *.zip ]]; then
|
|
emit_zip_fixture
|
|
else
|
|
emit_text "$filename"
|
|
fi
|
|
elif [[ -n "$output" && "$url" == *.zip ]]; then
|
|
emit_zip_fixture
|
|
else
|
|
echo "unexpected curl url: $url" >&2
|
|
exit 1
|
|
fi
|
|
"#,
|
|
shell_quote(curl_log),
|
|
shell_quote(zip_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,
|
|
server_info_url = TEST_SERVER_INFO_URL,
|
|
connection_group = TEST_CONNECTION_GROUP,
|
|
addressables_root = TEST_ADDRESSABLES_ROOT,
|
|
table_catalog_hash = xxhash32(b"ExcelDB.db"),
|
|
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"),
|
|
windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"),
|
|
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
|
|
android_media_catalog_hash = xxhash32(b"JP_Airi_Android.zip"),
|
|
)
|
|
}
|
|
|
|
fn launcher_unzip_script(resources_assets_path: &Path) -> String {
|
|
format!(
|
|
r#"#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
dest=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-d)
|
|
dest="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$dest/nested"
|
|
cp {} "$dest/nested/resources.assets"
|
|
"#,
|
|
shell_quote(resources_assets_path)
|
|
)
|
|
}
|
|
|
|
fn shell_quote(path: &Path) -> String {
|
|
let mut quoted = String::from("'");
|
|
for ch in path.to_string_lossy().chars() {
|
|
if ch == '\'' {
|
|
quoted.push_str("'\\''");
|
|
} else {
|
|
quoted.push(ch);
|
|
}
|
|
}
|
|
quoted.push('\'');
|
|
quoted
|
|
}
|
|
|
|
fn synthetic_resources_assets() -> Vec<u8> {
|
|
let json = r#"{
|
|
"SkipTutorial": true,
|
|
"Language": "ja-JP",
|
|
"DefaultConnectionGroup": "Fixture-Audit",
|
|
"ServerInfoDataUrl": "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
|
|
}"#;
|
|
let encrypted = encrypt_game_main_config(json);
|
|
let file = synthetic_serialized_file(&encrypted);
|
|
let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap();
|
|
assert_eq!(parsed.skip_tutorial, Some(true));
|
|
assert_eq!(parsed.language.as_deref(), Some("ja-JP"));
|
|
file
|
|
}
|
|
|
|
fn fetch_platform_inventory(
|
|
fetcher: &OfficialResourcePullService,
|
|
discovery: &YostarJpResourceDiscoveryPlan,
|
|
) -> YostarJpPlatformDownloadInventory {
|
|
let mut table_catalog = None;
|
|
let mut bundle_packing_infos = HashMap::<PatchPlatform, Vec<u8>>::new();
|
|
let mut media_catalogs = HashMap::<PatchPlatform, Vec<u8>>::new();
|
|
|
|
for endpoint in &discovery.endpoints {
|
|
match endpoint.kind {
|
|
YostarJpResourceEndpointKind::TableCatalog => {
|
|
table_catalog = Some(fetcher.fetch_bytes(&endpoint.url).unwrap());
|
|
}
|
|
YostarJpResourceEndpointKind::BundlePackingInfo => {
|
|
let platform = endpoint.platform.unwrap();
|
|
bundle_packing_infos.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap());
|
|
}
|
|
YostarJpResourceEndpointKind::MediaCatalog => {
|
|
let platform = endpoint.platform.unwrap();
|
|
media_catalogs.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let table_catalog = table_catalog.unwrap();
|
|
let platform_catalogs = verified_official_platforms()
|
|
.into_iter()
|
|
.map(|platform| {
|
|
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
|
platform,
|
|
bundle_packing_infos.get(&platform).unwrap(),
|
|
media_catalogs.get(&platform).unwrap(),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
YostarJpPlatformDownloadInventory::from_catalog_bytes(&table_catalog, platform_catalogs)
|
|
}
|
|
|
|
fn encrypt_game_main_config(json: &str) -> Vec<u8> {
|
|
let key = create_key("GameMainConfig");
|
|
let mut utf16 = json
|
|
.encode_utf16()
|
|
.flat_map(u16::to_le_bytes)
|
|
.collect::<Vec<_>>();
|
|
xor_bytes(&mut utf16, &key);
|
|
utf16
|
|
}
|
|
|
|
fn create_key(name: &str) -> Vec<u8> {
|
|
let seed = xxhash32(name.as_bytes());
|
|
let mut mt = MersenneTwister::new(seed);
|
|
mt.next_bytes(8)
|
|
}
|
|
|
|
fn xor_bytes(bytes: &mut [u8], key: &[u8]) {
|
|
if key.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for (index, byte) in bytes.iter_mut().enumerate() {
|
|
*byte ^= key[index % key.len()];
|
|
}
|
|
}
|
|
|
|
fn xxhash32(bytes: &[u8]) -> u32 {
|
|
const PRIME1: u32 = 0x9E37_79B1;
|
|
const PRIME2: u32 = 0x85EB_CA77;
|
|
const PRIME3: u32 = 0xC2B2_AE3D;
|
|
const PRIME4: u32 = 0x27D4_EB2F;
|
|
const PRIME5: u32 = 0x1656_67B1;
|
|
|
|
let len = bytes.len();
|
|
let mut index = 0usize;
|
|
let mut hash = if len >= 16 {
|
|
let mut v1 = PRIME1.wrapping_add(PRIME2);
|
|
let mut v2 = PRIME2;
|
|
let mut v3 = 0;
|
|
let mut v4 = 0u32.wrapping_sub(PRIME1);
|
|
|
|
while index + 16 <= len {
|
|
v1 = round(v1, read_u32_le(bytes, index));
|
|
v2 = round(v2, read_u32_le(bytes, index + 4));
|
|
v3 = round(v3, read_u32_le(bytes, index + 8));
|
|
v4 = round(v4, read_u32_le(bytes, index + 12));
|
|
index += 16;
|
|
}
|
|
|
|
v1.rotate_left(1)
|
|
.wrapping_add(v2.rotate_left(7))
|
|
.wrapping_add(v3.rotate_left(12))
|
|
.wrapping_add(v4.rotate_left(18))
|
|
} else {
|
|
PRIME5
|
|
}
|
|
.wrapping_add(len as u32);
|
|
|
|
while index + 4 <= len {
|
|
hash = hash
|
|
.wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3))
|
|
.rotate_left(17)
|
|
.wrapping_mul(PRIME4);
|
|
index += 4;
|
|
}
|
|
|
|
while index < len {
|
|
hash = hash
|
|
.wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5))
|
|
.rotate_left(11)
|
|
.wrapping_mul(PRIME1);
|
|
index += 1;
|
|
}
|
|
|
|
avalanche(hash)
|
|
}
|
|
|
|
fn round(acc: u32, input: u32) -> u32 {
|
|
const PRIME2: u32 = 0x85EB_CA77;
|
|
const PRIME1: u32 = 0x9E37_79B1;
|
|
|
|
acc.wrapping_add(input.wrapping_mul(PRIME2))
|
|
.rotate_left(13)
|
|
.wrapping_mul(PRIME1)
|
|
}
|
|
|
|
fn avalanche(mut hash: u32) -> u32 {
|
|
hash ^= hash >> 15;
|
|
hash = hash.wrapping_mul(0x85EB_CA6B);
|
|
hash ^= hash >> 13;
|
|
hash = hash.wrapping_mul(0xC2B2_AE35);
|
|
hash ^= hash >> 16;
|
|
hash
|
|
}
|
|
|
|
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
|
|
u32::from_le_bytes([
|
|
bytes[offset],
|
|
bytes[offset + 1],
|
|
bytes[offset + 2],
|
|
bytes[offset + 3],
|
|
])
|
|
}
|
|
|
|
struct MersenneTwister {
|
|
mt: [u32; 624],
|
|
index: usize,
|
|
}
|
|
|
|
impl MersenneTwister {
|
|
fn new(seed: u32) -> Self {
|
|
let mut mt = [0u32; 624];
|
|
mt[0] = seed;
|
|
for i in 1..624 {
|
|
mt[i] = 1812433253u32
|
|
.wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30))
|
|
.wrapping_add(i as u32);
|
|
}
|
|
|
|
Self { mt, index: 624 }
|
|
}
|
|
|
|
fn next_u32(&mut self) -> u32 {
|
|
if self.index >= 624 {
|
|
self.twist();
|
|
}
|
|
|
|
let mut y = self.mt[self.index];
|
|
self.index += 1;
|
|
y ^= y >> 11;
|
|
y ^= (y << 7) & 0x9D2C_5680;
|
|
y ^= (y << 15) & 0xEFC6_0000;
|
|
y ^= y >> 18;
|
|
y
|
|
}
|
|
|
|
fn next_bytes(&mut self, len: usize) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(len);
|
|
while out.len() < len {
|
|
let bytes = (self.next_u32() >> 1).to_le_bytes();
|
|
for byte in bytes {
|
|
if out.len() == len {
|
|
break;
|
|
}
|
|
out.push(byte);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn twist(&mut self) {
|
|
const UPPER_MASK: u32 = 0x8000_0000;
|
|
const LOWER_MASK: u32 = 0x7FFF_FFFF;
|
|
const MATRIX_A: u32 = 0x9908_B0DF;
|
|
|
|
for i in 0..624 {
|
|
let x = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % 624] & LOWER_MASK);
|
|
let mut x_a = x >> 1;
|
|
if x & 1 != 0 {
|
|
x_a ^= MATRIX_A;
|
|
}
|
|
self.mt[i] = self.mt[(i + 397) % 624] ^ x_a;
|
|
}
|
|
self.index = 0;
|
|
}
|
|
}
|
|
|
|
fn synthetic_serialized_file(bytes: &[u8]) -> Vec<u8> {
|
|
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
|
|
data.extend_from_slice(&value.to_be_bytes());
|
|
}
|
|
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
|
|
data.extend_from_slice(&value.to_be_bytes());
|
|
}
|
|
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
fn align(data: &mut Vec<u8>, alignment: usize) {
|
|
let remainder = data.len() % alignment;
|
|
if remainder != 0 {
|
|
data.resize(data.len() + alignment - remainder, 0);
|
|
}
|
|
}
|
|
|
|
let mut object_data = Vec::new();
|
|
push_u32_le(&mut object_data, 14);
|
|
object_data.extend_from_slice(b"GameMainConfig");
|
|
align(&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(&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
|
|
}
|