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:
@@ -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/`:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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')"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user