feat: migrate state and complete live systems

- replace the Haskell bridge with transactional SQLite state and validation

- centralize versions and harden resource, capture, and release tooling

- complete active gacha schedules, point exchange, and mail grants

- add equipment refinement, presets, and ordinary formation workflows

- improve client plugins, request timing, and normalized persistence
This commit is contained in:
2026-09-24 22:43:03 +08:00
parent 113e941bef
commit f94cfa1cf3
162 changed files with 13714 additions and 12632 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb `
## 临时邮件物品发放
`dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。另提供单一金币货币条目 `type4/id0`,填写的数量在领取后直接叠加至钱包,不再发送“金币随机箱”。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。
`dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。物品名按来源表引用的文本命名空间解析;确定性随机箱使用 `RandomBoxTextTable` 的真实名称作为内容物搜索别名,因此不依赖固定物品 ID。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。另提供单一金币货币条目 `type4/id0`,填写的数量在领取后直接叠加至钱包,不再发送“金币随机箱”。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
+6 -3
View File
@@ -13,10 +13,14 @@ import json
from pathlib import Path
import re
import shutil
import tempfile
from typing import Iterable
import unicodedata
try:
from .inherited_stage import create_inherited_stage
except ImportError: # Direct script execution.
from inherited_stage import create_inherited_stage
TOOL = "bd2.deobfuscate_client_source"
MANIFEST = ".bd2-deobfuscate-manifest.json"
@@ -67,8 +71,7 @@ def prepare_stage(output: Path) -> Path:
raise FileExistsError(
f"refusing to overwrite non-managed output directory: {output}"
)
output.parent.mkdir(parents=True, exist_ok=True)
return Path(tempfile.mkdtemp(prefix=f".{output.name}.", dir=output.parent))
return create_inherited_stage(output)
def publish_stage(stage: Path, output: Path) -> None:
+50 -17
View File
@@ -132,6 +132,18 @@ def _usable_display_name(value: str) -> bool:
return bool(value) and not value.startswith("<未找到本地化文本 #") and value not in {"(不使用)", "(不使用)"}
def _localized_names(connection: sqlite3.Connection, table: str) -> dict[int, str]:
"""Read the common localized-text protobuf shape from its owning table."""
result: dict[int, str] = {}
for row_id, proto in connection.execute(f'SELECT id, ProtoBuf FROM "{table}"'):
decoded = fields(proto)
text_id = first_varint(decoded, 2) or int(row_id)
name = first_text(decoded, 4) or first_text(decoded, 5) or first_text(decoded, 3)
if name:
result[text_id] = name
return result
def _safe_direct_mail_item(item: dict[str, Any]) -> bool:
# RandomBox requires a second protocol and its entered count is not the
# final reward count, so this direct-mail form never exposes type 9.
@@ -157,6 +169,7 @@ def map_fixed_boxes_to_direct_items(
"""
by_key = {(item["element_type"], item["id"]): item for item in items}
target_aliases: dict[tuple[int, int], dict[str, int]] = {}
box_aliases: dict[tuple[int, int], dict[str, int]] = {}
source_boxes: dict[tuple[int, int], list[tuple[int, int]]] = {}
for box_id, (reward_type, reward_id, reward_count) in fixed_boxes.items():
target_key = (reward_type, reward_id)
@@ -165,28 +178,49 @@ def map_fixed_boxes_to_direct_items(
if target is None or box is None:
continue
source_boxes.setdefault(target_key, []).append((box_id, reward_count))
if _usable_display_name(box["name"]):
aliases = box_aliases.setdefault(target_key, {})
aliases[box["name"]] = aliases.get(box["name"], 0) + 1
aliases = target_aliases.setdefault(target_key, {})
for alias, frequency in product_aliases.get(box_id, {}).items():
if _usable_display_name(alias):
aliases[alias] = aliases.get(alias, 0) + frequency
for target_key, aliases in target_aliases.items():
for target_key, boxes in source_boxes.items():
target = by_key[target_key]
original_name = target["name"]
aliases = target_aliases.get(target_key, {})
canonical = max(aliases, key=lambda value: (aliases[value], -len(value), value)) if aliases else ""
if not _usable_display_name(target["name"]):
box_names = box_aliases.get(target_key, {})
# When every named deterministic wrapper agrees on one material name,
# that name is the most specific GameData label for an otherwise valid
# generic resource row. Keep the generic table name searchable as an
# alias. If the resource itself has no usable name, retain the product
# fallback below because a lone wrapper can still have a generic label.
box_name = next(iter(box_names)) if len(box_names) == 1 else ""
if _usable_display_name(original_name) and box_name and box_name != original_name:
target["name"] = box_name
elif not _usable_display_name(original_name):
if canonical:
target["name"] = canonical
# Keep only the dominant authoritative product label. Minority product
# names can describe expiry/conversion products (for example a ticket
# which converts to 女神之泪) and must not pollute direct-item search.
target["aliases"] = [canonical] if canonical and canonical != target["name"] else []
boxes = source_boxes[target_key]
# Deterministic RandomBox names come from RandomBoxTextTable and describe
# the actual contained material. Product labels remain a secondary
# fallback because some products describe expiry/conversion behavior.
target["aliases"] = []
for value in (original_name, box_name, canonical):
if _usable_display_name(value) and value != target["name"] and value not in target["aliases"]:
target["aliases"].append(value)
preview = "、".join(str(box_id) for box_id, _ in boxes[:4])
if len(boxes) > 4:
preview += f" 等 {len(boxes)} 个"
details = ["开发邮件直接发放此物品(无需开箱)"]
if original_name != target["name"] and _usable_display_name(original_name):
details.append("原始资源名:" + original_name)
if box_name and box_name != target["name"]:
details.append("确定性箱名称:" + box_name)
if target["aliases"]:
details.append("商品名:" + canonical)
if canonical and canonical != target["name"]:
details.append("商品名:" + canonical)
details.append("固定箱映射:" + preview)
target["details"] = ";".join(details)
@@ -197,17 +231,16 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]:
"""Return every safe ItemDBInfo-backed static item, with Chinese names."""
connection, temporary = open_readonly_database(root, version)
try:
names: dict[int, str] = {}
for row_id, proto in connection.execute("SELECT id, ProtoBuf FROM LocalTextTable"):
decoded = fields(proto)
# LocalTextTable: id=2, text_cn=4, text_en=5 (client descriptor).
text_id = first_varint(decoded, 2) or int(row_id)
name = first_text(decoded, 4) or first_text(decoded, 5) or first_text(decoded, 3)
if name:
names[text_id] = name
# Most inventory tables refer to LocalTextTable. RandomBoxTable owns a
# separate RandomBoxTextTable namespace with the same localized-text
# protobuf shape; resolving it through LocalTextTable silently loses
# material names when numeric text IDs do not overlap.
names = _localized_names(connection, "LocalTextTable")
random_box_names = _localized_names(connection, "RandomBoxTextTable")
items: list[dict[str, Any]] = []
for table, element_type, id_field, name_field, category in ITEM_SOURCES:
text_names = random_box_names if table == "RandomBoxTable" else names
for row_id, proto in connection.execute(f"SELECT id, ProtoBuf FROM {table} ORDER BY id"):
decoded = fields(proto)
item_id = first_varint(decoded, id_field) or int(row_id)
@@ -215,7 +248,7 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]:
items.append({
"id": item_id,
"element_type": element_type,
"name": names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
"name": text_names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
"category": category,
"source_table": table,
"name_text_id": name_text_id,
+6 -3
View File
@@ -15,10 +15,14 @@ import json
from pathlib import Path, PurePosixPath
import re
import shutil
import tempfile
from google.protobuf import descriptor_pb2
try:
from .inherited_stage import create_inherited_stage
except ImportError: # Direct script execution.
from inherited_stage import create_inherited_stage
TOOL = "bd2.extract_client_proto"
MANIFEST = ".bd2-proto-extract-manifest.json"
@@ -83,8 +87,7 @@ def is_managed(output: Path) -> bool:
def stage_for(output: Path) -> Path:
if output.exists() and not is_managed(output):
raise FileExistsError(f"refusing to overwrite non-managed output directory: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
return Path(tempfile.mkdtemp(prefix=f".{output.name}.", dir=output.parent))
return create_inherited_stage(output)
def extract_descriptor(path: Path) -> descriptor_pb2.FileDescriptorProto | None:
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Download a BD2 ServerData catalog and GameData version without HTTP ranges."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
import zipfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
SERVERDATA_HOST = "https://bd2-cdn.akamaized.net/ServerData"
GAMEDATA_HOST = "https://bd2-cdn.akamaized.net/GameData"
PLACEHOLDER = "{BDNetwork.CdnInfo.Info}"
_print_lock = threading.Lock()
def log(message: str) -> None:
with _print_lock:
print(message, flush=True)
def get_bytes(url: str, timeout: int = 300) -> bytes:
# Deliberately no Range header: Akamai can return a length-correct but
# corrupt bundle when a resource is assembled from partial responses.
request = urllib.request.Request(url, method="GET", headers={"Accept-Encoding": "identity"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def atomic_write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".part")
temporary.write_bytes(data)
os.replace(temporary, path)
def internal_paths(catalog: dict) -> list[str]:
result: set[str] = set()
for internal_id in catalog.get("m_InternalIds", []):
if not isinstance(internal_id, str) or not internal_id.startswith(PLACEHOLDER):
continue
value = internal_id.replace("\\", "/")
parts = value.split("/")
# Placeholder/platform/resolution/version/<bundle path>
if len(parts) >= 5:
candidate = "/".join(parts[4:]).lstrip("/")
if candidate:
result.add(candidate)
if not result:
raise ValueError("catalog contains no BDNetwork.CdnInfo.Info bundle paths")
return sorted(result)
def download_one(base: str, relative: str, destination: Path, validate_bundle: bool) -> tuple[str, int, str]:
target = destination / relative.replace("/", os.sep)
if target.is_file() and target.stat().st_size > 0:
first = target.open("rb").read(7)
if not validate_bundle or first[:7] == b"UnityFS":
return relative, target.stat().st_size, "skip"
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(target.name + ".part")
url = base.rstrip("/") + "/" + urllib.parse.quote(relative, safe="/._-=")
last_error: Exception | None = None
for attempt in range(1, 5):
try:
temporary.unlink(missing_ok=True)
completed = subprocess.run(
[
"curl", "-sS", "-L", "--fail", "--connect-timeout", "30",
"--max-time", "600", "--speed-limit", "1024", "--speed-time", "30",
"--output", str(temporary), url,
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or f"curl exit {completed.returncode}")
size = temporary.stat().st_size
if size == 0:
raise ValueError("empty response")
magic = temporary.open("rb").read(16)
if validate_bundle and magic[:7] != b"UnityFS":
raise ValueError(f"missing UnityFS magic: {magic!r}")
os.replace(temporary, target)
return relative, size, "download"
except Exception as exc: # retry transient CDN failures
last_error = exc
temporary.unlink(missing_ok=True)
if attempt < 4:
time.sleep(attempt * 2)
raise RuntimeError(f"{relative}: {last_error}")
def fetch_serverdata(args: argparse.Namespace, root: Path) -> dict:
base = f"{SERVERDATA_HOST}/{args.platform}/HD/{args.bundle_version}"
catalog_url = base + "/catalog_alpha.json"
catalog_hash_url = base + "/catalog_alpha.hash"
catalog_bytes = get_bytes(catalog_url)
catalog_hash_bytes = get_bytes(catalog_hash_url)
if not catalog_bytes.startswith(b"{"):
raise ValueError(f"catalog is not JSON: {catalog_bytes[:16]!r}")
catalog_hash = catalog_hash_bytes.decode("ascii").strip()
if not re.fullmatch(r"[0-9a-fA-F]{32}", catalog_hash):
raise ValueError(f"catalog hash is not a 32-digit hex value: {catalog_hash!r}")
catalog = json.loads(catalog_bytes.decode("utf-8"))
paths = internal_paths(catalog)
destination = root / "ServerData" / args.platform / "HD" / args.bundle_version
atomic_write(destination / "catalog_alpha.json", catalog_bytes)
atomic_write(destination / "catalog_alpha.hash", (catalog_hash + "\n").encode("ascii"))
log(f"ServerData {args.bundle_version}: {len(paths)} bundles -> {destination}")
failures: list[str] = []
done = 0
total_bytes = 0
if args.workers == 1:
for path in paths:
try:
_, size, mode = download_one(base, path, destination, True)
done += 1
total_bytes += size
if done % 25 == 0 or done == len(paths):
log(f"ServerData progress {done}/{len(paths)} ({total_bytes / 1024**3:.2f} GiB, {mode})")
except Exception as exc:
failures.append(str(exc))
log(f"ServerData failed: {exc}")
else:
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(download_one, base, path, destination, True) for path in paths]
for future in futures:
try:
_, size, mode = future.result()
done += 1
total_bytes += size
if done % 25 == 0 or done == len(paths):
log(f"ServerData progress {done}/{len(paths)} ({total_bytes / 1024**3:.2f} GiB, {mode})")
except Exception as exc:
failures.append(str(exc))
if failures:
raise RuntimeError("ServerData failures:\n" + "\n".join(failures[:20]))
return {
"catalog_url": catalog_url,
"catalog_hash_url": catalog_hash_url,
"catalog_hash": catalog_hash.lower(),
"bundles": len(paths),
"bytes": total_bytes,
}
def fetch_gamedata(args: argparse.Namespace, root: Path) -> dict:
files = ["design.version", "release/common-dbdata.info", "release/common-dbdata.bin"]
# The first entry is outside release; preserve the server's URL layout.
records = []
for relative in files:
url = f"{GAMEDATA_HOST}/{args.game_data_version}/{relative}"
target = root / "GameData" / args.game_data_version / relative.replace("/", os.sep)
if target.is_file() and target.stat().st_size > 0:
data = target.read_bytes()
mode = "existing"
else:
data = get_bytes(url)
if not data:
raise ValueError(f"empty GameData response: {url}")
atomic_write(target, data)
mode = "downloaded"
records.append({"path": relative, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()})
log(f"GameData {args.game_data_version}: {relative} ({len(data) / 1024**2:.1f} MiB, {mode})")
archive = root / "GameData" / args.game_data_version / "release" / "common-dbdata.bin"
if archive.open("rb").read(4) != b"PK\x03\x04":
raise ValueError(f"GameData archive is missing ZIP magic: {archive}")
with zipfile.ZipFile(archive) as package:
damaged = package.testzip()
if damaged is not None:
raise ValueError(f"GameData ZIP CRC failed at {damaged!r}")
members = len(package.infolist())
info_path = root / "GameData" / args.game_data_version / "release" / "common-dbdata.info"
expected_size = int(info_path.read_text(encoding="utf-8").strip())
if archive.stat().st_size != expected_size:
raise ValueError(f"GameData size mismatch: {archive.stat().st_size} != {expected_size}")
log(f"GameData ZIP verified: {members} entries, {expected_size} bytes")
return {"version": args.game_data_version, "files": records}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--bundle-version", required=True)
parser.add_argument("--game-data-version", required=True)
parser.add_argument("--platform", default="StandaloneWindows64")
parser.add_argument("--output-root", type=Path, required=True)
parser.add_argument("--workers", type=int, default=16)
args = parser.parse_args()
if not re.fullmatch(r"\d{14}", args.bundle_version) or not re.fullmatch(r"\d{14}", args.game_data_version):
parser.error("versions must be 14-digit timestamps")
root = args.output_root.resolve()
root.mkdir(parents=True, exist_ok=True)
result = {
"bundle_version": args.bundle_version,
"game_data_version": args.game_data_version,
"game_data": fetch_gamedata(args, root),
"server_data": fetch_serverdata(args, root),
}
atomic_write(root / "resource-fetch-manifest.json", (json.dumps(result, indent=2) + "\n").encode())
log(f"resource fetch complete: {root / 'resource-fetch-manifest.json'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Read and inspect Brown Dust II 2.34.13 GameData without building Go.
"""Read and inspect a Brown Dust II GameData version without building Go.
The tool opens common-dbdata.bin as a ZIP, extracts one encrypted SQLite
member, decrypts each 4096-byte AES-CBC page independently, and exposes only
+29
View File
@@ -0,0 +1,29 @@
"""Create atomic-output staging directories without restricting Windows ACLs."""
from __future__ import annotations
import os
from pathlib import Path
import secrets
def create_inherited_stage(output: Path) -> Path:
"""Create a sibling staging directory that inherits its parent's access.
Python 3.13 gives ``tempfile.mkdtemp`` directories created with mode 0700 a
deliberately restricted Windows ACL. Renaming such a directory into place
preserves that ACL, which is unsuitable for generated, shared development
data. ``os.mkdir`` without the special 0700 mode inherits the parent ACL on
Windows and retains the normal umask-controlled behaviour on POSIX.
"""
output = Path(output)
output.parent.mkdir(parents=True, exist_ok=True)
prefix = f".{output.name}."
for _ in range(100):
stage = output.parent / f"{prefix}{secrets.token_hex(8)}"
try:
os.mkdir(stage)
except FileExistsError:
continue
return stage
raise FileExistsError(f"unable to create a unique staging directory for {output}")
+90 -11
View File
@@ -3,7 +3,15 @@ param(
[string]$GameDir,
[Parameter(Mandatory)]
[string]$LocalClientDir,
[string]$ResourceRoot,
[ValidatePattern('^\d{14}$')]
[string]$BundleVersion,
[ValidatePattern('^\d{14}$')]
[string]$GameDataVersion,
[string]$VersionConfig = (Join-Path $PSScriptRoot '..\..\versions.json'),
[string]$SharedDataDir,
@@ -12,8 +20,19 @@ param(
$ErrorActionPreference = 'Stop'
$VersionConfig = [IO.Path]::GetFullPath($VersionConfig)
if (-not (Test-Path -LiteralPath $VersionConfig -PathType Leaf)) {
throw "Version config is missing: $VersionConfig"
}
$versions = Get-Content -LiteralPath $VersionConfig -Raw | ConvertFrom-Json
if (-not $BundleVersion) { $BundleVersion = [string]$versions.bundle_version }
if (-not $GameDataVersion) { $GameDataVersion = [string]$versions.game_data_version }
if ($BundleVersion -notmatch '^\d{14}$' -or $GameDataVersion -notmatch '^\d{14}$') {
throw "Invalid resource version in $VersionConfig"
}
$GameDir = [IO.Path]::GetFullPath($GameDir)
$LocalClientDir = [IO.Path]::GetFullPath($LocalClientDir)
$ResourceRoot = [IO.Path]::GetFullPath($ResourceRoot)
if (-not $SharedDataDir) {
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
$SharedDataDir = Join-Path (Split-Path -Parent $localAppData) 'LocalLow\Gamfs\BrownDust II'
@@ -27,14 +46,15 @@ $capturePlugin = Join-Path $pluginDir 'BD2CaptureEnvironment.dll'
$identityPlugin = Join-Path $pluginDir 'BD2LocalIdentity.dll'
$doorstop = Join-Path $GameDir 'doorstop_config.ini'
$isolatedData = Join-Path $GameDir 'IsolatedUserData'
$sharedGameData = Join-Path $SharedDataDir 'Data\t'
$stagedCapturePlugin = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bin\Release\netstandard2.1\BD2CaptureEnvironment.dll'
$stagedLocalResConfig = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bd2.localres.cfg'
$localResConfig = Join-Path $GameDir 'BepInEx\config\bd2.localres.cfg'
if (Get-Process -Name 'BrownDust II' -ErrorAction SilentlyContinue) {
throw 'BrownDust II is already running. Exit it before starting the isolated official client.'
}
foreach ($requiredDirectory in @($GameDir, $LocalClientDir, $SharedDataDir, $sharedGameData)) {
foreach ($requiredDirectory in @($GameDir, $ResourceRoot, $SharedDataDir)) {
if (-not (Test-Path -LiteralPath $requiredDirectory -PathType Container)) {
throw "Required directory is missing: $requiredDirectory"
}
@@ -58,6 +78,37 @@ foreach ($required in @($exe, $doorstop, $localRes, $capturePlugin)) {
}
}
if (-not (Test-Path -LiteralPath $stagedLocalResConfig -PathType Leaf)) {
throw "Local-resource config is missing: $stagedLocalResConfig"
}
$resourceManifest = Join-Path $ResourceRoot 'resource-fetch-manifest.json'
if (-not (Test-Path -LiteralPath $resourceManifest -PathType Leaf)) {
throw "Resource download is incomplete; manifest is missing: $resourceManifest"
}
$resourceState = Get-Content -LiteralPath $resourceManifest -Raw | ConvertFrom-Json
if ($resourceState.bundle_version -ne $BundleVersion -or
$resourceState.game_data_version -ne $GameDataVersion) {
throw "Resource manifest version mismatch: $resourceManifest"
}
$catalogDir = Join-Path $ResourceRoot "ServerData\StandaloneWindows64\HD\$BundleVersion"
$catalogJson = Join-Path $catalogDir 'catalog_alpha.json'
$catalogHash = Join-Path $catalogDir 'catalog_alpha.hash'
foreach ($catalogFile in @($catalogJson, $catalogHash)) {
if (-not (Test-Path -LiteralPath $catalogFile -PathType Leaf)) {
throw "Addressables catalog is incomplete: $catalogFile"
}
}
$actualCatalogHash = (Get-Content -LiteralPath $catalogHash -Raw).Trim().ToLowerInvariant()
if ($actualCatalogHash -notmatch '^[0-9a-f]{32}$' -or
[string]$resourceState.server_data.catalog_hash -ne $actualCatalogHash) {
throw "Addressables catalog hash does not match the resource manifest: $catalogHash"
}
$localResText = Get-Content -LiteralPath $stagedLocalResConfig -Raw
$localResText = $localResText.Replace('<RESOURCE_ROOT>', $ResourceRoot)
$localResText = $localResText.Replace('<BUNDLE_VERSION>', $BundleVersion)
$localResText = $localResText.Replace('<GAME_DATA_VERSION>', $GameDataVersion)
[IO.File]::WriteAllText($localResConfig, $localResText, [Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $identityPlugin) {
throw "Unsafe plugin found in official environment: $identityPlugin"
}
@@ -70,14 +121,34 @@ if ($unexpectedIdentity) {
$sharedStaticPaths = @{
(Join-Path $isolatedData 'SoundData') = (Join-Path $SharedDataDir 'SoundData')
(Join-Path $isolatedData 'com.unity.addressables') = (Join-Path $SharedDataDir 'com.unity.addressables')
(Join-Path $isolatedData 'VideoData') = (Join-Path $SharedDataDir 'VideoData')
(Join-Path $GameDir 'BrownDust II_Data\StreamingAssets\aa') = (Join-Path $LocalClientDir 'BrownDust II_Data\StreamingAssets\aa')
}
$bundledAA = Join-Path $GameDir 'BrownDust II_Data\StreamingAssets\aa'
$bundledItem = Get-Item -LiteralPath $bundledAA -Force -ErrorAction SilentlyContinue
if (-not $bundledItem -or -not $bundledItem.PSIsContainer -or $bundledItem.LinkType) {
throw "Bundled StreamingAssets/aa must be a real directory: $bundledAA"
}
New-Item -ItemType Directory -Force -Path $isolatedData | Out-Null
$isolatedCatalogDir = Join-Path $isolatedData 'com.unity.addressables'
New-Item -ItemType Directory -Force -Path $isolatedCatalogDir | Out-Null
# The packaged settings.json names this exact persistent catalog cache. Stage
# both files together so a previously written bundle_version PlayerPrefs value
# cannot leave the bundled catalog paired with a newer ServerData directory.
Copy-Item -LiteralPath $catalogJson `
-Destination (Join-Path $isolatedCatalogDir 'catalog_alpha.json') -Force
Copy-Item -LiteralPath $catalogHash `
-Destination (Join-Path $isolatedCatalogDir 'catalog_alpha.hash') -Force
foreach ($link in $sharedStaticPaths.GetEnumerator()) {
$item = Get-Item -LiteralPath $link.Key -Force -ErrorAction SilentlyContinue
if (-not $item -or $item.LinkType -ne 'Junction') {
throw "Shared static-data junction is missing: $($link.Key)"
if (-not (Test-Path -LiteralPath $link.Value -PathType Container)) {
throw "Shared static-data source is missing: $($link.Value)"
}
if (-not $item) {
New-Item -ItemType Junction -Path $link.Key -Target $link.Value | Out-Null
$item = Get-Item -LiteralPath $link.Key -Force
}
if ($item.LinkType -ne 'Junction') {
throw "Static-data destination must be a junction: $($link.Key)"
}
$actualTarget = [IO.Path]::GetFullPath([string]$item.Target)
$expectedTarget = [IO.Path]::GetFullPath([string]$link.Value)
@@ -86,13 +157,21 @@ foreach ($link in $sharedStaticPaths.GetEnumerator()) {
}
}
New-Item -ItemType Directory -Force -Path $isolatedData | Out-Null
$unityLog = Join-Path $isolatedData 'Player.log'
$process = Start-Process -FilePath $exe -WorkingDirectory $GameDir `
-ArgumentList @(
'-logFile', ('"' + $unityLog + '"'),
'-bd2SharedGameData', ('"' + $sharedGameData + '"')
'-logFile', ('"' + $unityLog + '"')
) -PassThru
Start-Sleep -Milliseconds 500
$actualProcess = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
if ($actualProcess -and
-not [string]::Equals(
[IO.Path]::GetFullPath($actualProcess.Path),
[IO.Path]::GetFullPath($exe),
[StringComparison]::OrdinalIgnoreCase)) {
Stop-Process -Id $process.Id -Force
throw "Wrong client executable started: $($actualProcess.Path)"
}
Write-Host "Started isolated official client (PID $($process.Id))."
Write-Host "BepInEx log: $(Join-Path $GameDir 'BepInEx\LogOutput.log')"
Write-Host "Capture root: $(Join-Path $GameDir 'Capture')"
+49
View File
@@ -21,6 +21,7 @@ import import_seed
import deobfuscate_client_source
import extract_client_proto
import dev_mail_grant
import inherited_stage
class Arguments:
@@ -120,8 +121,36 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
material = next(item for item in mapped if item["element_type"] == 8 and item["id"] == 127)
self.assertEqual(material["name"], "女神之泪")
self.assertIn("无需开箱", material["details"])
self.assertIn("装备制作所需材料", material["details"])
self.assertIn("400131", material["details"])
def test_single_consistent_box_name_becomes_the_material_display_name(self):
items = [
{"id": 400045, "element_type": 9, "name": "精炼粉末", "category": "随机箱"},
{"id": 10, "element_type": 8, "name": "强化袋", "category": "资源"},
]
mapped = dev_mail_grant.map_fixed_boxes_to_direct_items(
items,
{400045: (8, 10, 5)},
{},
)
material = next(item for item in mapped if item["element_type"] == 8 and item["id"] == 10)
self.assertEqual(material["name"], "精炼粉末")
self.assertIn("原始资源名:强化袋", material["details"])
def test_localized_names_reads_the_requested_text_namespace(self):
import sqlite3
connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE RandomBoxTextTable (id INTEGER, ProtoBuf BLOB)")
name = "精炼粉末".encode("utf-8")
connection.execute(
"INSERT INTO RandomBoxTextTable VALUES (?, ?)",
(7, b"\x10\x07\x22" + bytes([len(name)]) + name),
)
self.assertEqual(dev_mail_grant._localized_names(connection, "RandomBoxTextTable"), {7: "精炼粉末"})
connection.close()
def test_internal_lost_resource_is_not_mail_safe(self):
self.assertFalse(dev_mail_grant._safe_direct_mail_item({
"id": 90045, "element_type": 8, "name": "金币遗失物品",
@@ -194,6 +223,26 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
class ClientSourceToolTests(unittest.TestCase):
def test_generated_tree_staging_uses_parent_inheriting_directory_mode(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "dev" / "client-source-readable"
real_mkdir = inherited_stage.os.mkdir
calls = []
def recording_mkdir(path, *args, **kwargs):
calls.append((Path(path), args, kwargs))
return real_mkdir(path, *args, **kwargs)
with mock.patch.object(inherited_stage.os, "mkdir", side_effect=recording_mkdir):
stage = inherited_stage.create_inherited_stage(output)
try:
self.assertEqual(stage.parent, output.parent)
stage_call = next(call for call in calls if call[0] == stage)
self.assertEqual(stage_call[1], ())
self.assertEqual(stage_call[2], {})
finally:
stage.rmdir()
def test_deobfuscates_code_not_comments_or_literals_and_records_collisions(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)