feat: expand growth, equipment, and mail

- add character promotion and costume potential flows
- add equipment management and enhancement
- harden development mail and save tools
This commit is contained in:
2026-09-21 22:57:53 +08:00
parent a7c065eaa5
commit f55a71ac52
47 changed files with 6237 additions and 97 deletions
+59
View File
@@ -8,6 +8,28 @@ Python 版本为 3.11 以上;GameData 分页 AES 解密需要 `pycryptodome`
python -m pip install pycryptodome
```
## 客户端源码镜像与 Proto 查看
客户端反编译源码、混淆翻译表和官方会话抓包是开发取证输入;这些工具只读取客户端目录,绝不原地改写。两个输出目录都必须位于 `--source` 之外。为避免清空人工文件,输出已存在时只有带本工具清单的目录才能被重新生成。
```powershell
# 用 ObfuscationTranslation 的 “混淆名⇨含义” 映射生成可检索 C# 镜像。
# 替换只作用于代码中的 C# 标识符;注释、字符串和字符字面量原样保留。
python .\tools\python\deobfuscate_client_source.py `
--source "<Assembly-CSharp目录>" `
--mapping "<ObfuscationTranslation.obfuscate>" `
--output .\tmp\client-source-readable
# 从 *Reflection.cs 内嵌的 FileDescriptorProto 重建真正的 .proto。
python .\tools\python\extract_client_proto.py `
--source "<Assembly-CSharp目录>" `
--output .\tmp\client-proto
```
反混淆输出根下的 `.bd2-deobfuscate-manifest.json` 记录有效映射、冲突改名、未处理警告和替换统计。映射含命名空间或路径时,工具取末段并规范为一个合法标识符;发生同名时加入稳定后缀。该镜像用于阅读与检索,并不承诺可编译。
Proto 输出根按 descriptor 原始文件名保存可读 `.proto`;`client-descriptors.pb` 是无损 `FileDescriptorSet`,用于保留文本渲染器暂未展开的复杂 options。`.bd2-proto-extract-manifest.json` 记录 package、依赖、源 Reflection、descriptor/.proto SHA-256 和渲染警告。
## 查询 GameData
无需运行 `go test` 或构建服务端:
@@ -84,6 +106,43 @@ python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检
python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 --apply
```
开发期存档格式发生变化时,不在运行时加入旧格式猜测或兼容分支。应编写一次性 Python 编辑器,先演练,再在客户端和服务端停止后创建完整检查点并原子改档。例如首次抽卡完成状态:
```powershell
python .\tools\python\set_first_gacha_state.py --state .\data\state --completed
python .\tools\python\set_first_gacha_state.py --state .\data\state --completed --apply
```
正式运行时代码只接受编辑后的最终语义;开发工具不进入发布包。
## 临时邮件物品发放
`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` 的九份账号状态。
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
```powershell
python .\tools\python\dev_mail_grant.py serve `
--game-data "E:\bd2\dl\GameData" `
--game-data-version "20260910162539" `
--mail-seed .\go\seed\v2_34_13\mail.json `
--output .\data\dev\mail-grants.json
```
工具启动时立即原子写出规范化的完整 `--output`(尚未发放也一样),因此首次启用时可先启动工具、再让本地服务端监听这个输出文件。每次发放同样原子更新该文件。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务仅在启动时需要加入(或替换为)以下参数:
```powershell
--mail-seed ".\data\dev\mail-grants.json"
```
客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`;每封工具邮件固定只有一个附件。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。客户端邮箱 UI 按一次请求加载最多 100 封普通邮件,现有本地服务目前回传全部未开封邮件,故大量历史未领取邮件的实际 UI 表现尚待验证。现有本地 `/MailOpen` 对同一邮件 ID 的领取由 `data/state/mail.json` 的 `opened` 集合持久化,重试不会重复发奖;工具会在完整种子中分配唯一递增邮件 ID。
曾从开发工具发放的 `ResourceTable 90045`(金币遗失物品)会在客户端邮件详情打开时因缺少 `CostumeTable id405` 崩溃。修复工具 `repair_invalid_dev_mail_resources.py` 默认只演练;确需移除已误领的 `90045/90046` 实例时,先停止客户端与服务端,再用 `--apply` 自动创建、校验九文件备份并只修 `items.json`,保留邮件已领取与发放台账。
这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。客户端 `DataManager.GetItemDTO` 对 `ContentTicket`(19)和 `LobbySettingItem`(25)没有可用于 `ItemDBInfo` 领取的 DTO 分支,故也没有提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐这些类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。
热载只接受经过 `mail.Starter.Validate` 校验的完整 JSON 种子:文件未变化时不会重新读取;被检测到的坏替换会保留上一次已验证邮箱,并使该次 `/MailInfo` 请求失败而不会部分加载。工具本身总是完整写临时文件、`fsync` 后原子替换,正常发放不会让服务器看到半文件。
## 任务 38 定向回档
该工具只撤销 pack21 quest38 的进度、四项物品和 1500 金币,保留其他账号资产;兼容旧存档和 v2 `pack:quest` 键:
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Create a searchable, read-only deobfuscated mirror of C# client source.
The original source tree is never modified. Mapping files use ``left⇨right``;
``#ReverseOrder`` reverses mappings on subsequent lines. Output is a mirror
with a JSON manifest describing every effective replacement and limitation.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import shutil
import tempfile
from typing import Iterable
import unicodedata
TOOL = "bd2.deobfuscate_client_source"
MANIFEST = ".bd2-deobfuscate-manifest.json"
ARROW = "⇨"
CSHARP_KEYWORDS = {
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
"object", "operator", "out", "override", "params", "private", "protected", "public",
"readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc",
"static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof",
"uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void",
"volatile", "while",
}
def is_within(child: Path, parent: Path) -> bool:
try:
child.relative_to(parent)
return True
except ValueError:
return False
def require_distinct_trees(source: Path, output: Path) -> tuple[Path, Path]:
source, output = source.resolve(), output.resolve()
if not source.is_dir():
raise ValueError(f"source is not a directory: {source}")
if source == output or is_within(output, source) or is_within(source, output):
raise ValueError("output must be outside, and not contain, source")
return source, output
def managed_output(output: Path) -> bool:
manifest = output / MANIFEST
if not manifest.is_file():
return False
try:
return json.loads(manifest.read_text(encoding="utf-8")).get("tool") == TOOL
except (OSError, json.JSONDecodeError):
return False
def prepare_stage(output: Path) -> Path:
if output.exists() and not managed_output(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))
def publish_stage(stage: Path, output: Path) -> None:
if output.exists():
# The caller already established that this is a directory created by us.
shutil.rmtree(output)
stage.replace(output)
def identifier_from_meaning(value: str) -> str:
"""Turn a map value into one legal, readable C# identifier.
Translation databases sometimes store a qualified path such as
``Net.Player/UserInfo``. An identifier replacement must be a single token,
so the useful terminal component is selected before normalising it.
"""
parts = [part for part in re.split(r"(?:\.|::|/|\\)+", value.strip()) if part]
raw = parts[-1] if parts else value.strip()
result = []
for index, char in enumerate(raw):
if (char == "_" or char.isascii() and char.isalpha() or
index > 0 and char.isascii() and char.isdigit()):
result.append(char)
elif char.isascii() and char.isdigit() and index == 0:
result.extend(("_", char))
else:
result.append("_")
name = "".join(result).strip("_") or "unnamed"
if name[0].isdigit():
name = "_" + name
if name in CSHARP_KEYWORDS:
name = "_" + name
return name
def is_identifier_start(char: str) -> bool:
return char == "_" or unicodedata.category(char) in {
"Lu", "Ll", "Lt", "Lm", "Lo", "Nl",
}
def is_identifier_continue(char: str) -> bool:
return is_identifier_start(char) or unicodedata.category(char) in {
"Mn", "Mc", "Nd", "Pc", "Cf",
}
def parse_mapping(path: Path) -> tuple[list[dict], list[str]]:
"""Read the mapping while retaining malformed/conflicting entries as notes."""
entries: list[dict] = []
warnings: list[str] = []
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith("//"):
continue
if line.casefold() == "#reverseorder":
# Official files carry this as format metadata, but their actual
# rows are still visibly obfuscated-name ⇨ readable-name.
continue
if ARROW not in line:
if not line.startswith("#"):
warnings.append(f"line {line_number}: ignored (no {ARROW!r})")
continue
left, right = (part.strip() for part in line.split(ARROW, 1))
source, meaning = left, right
if not source or not meaning:
warnings.append(f"line {line_number}: ignored (empty mapping side)")
continue
if not source or not is_identifier_start(source[0]) or not all(
is_identifier_continue(char) for char in source[1:]):
warnings.append(f"line {line_number}: ignored (non-identifier source {source!r})")
continue
entries.append({
"line": line_number,
"source": source,
"meaning": meaning,
})
return entries, warnings
def build_replacements(entries: Iterable[dict]) -> tuple[dict[str, str], list[dict], list[str]]:
replacements: dict[str, str] = {}
report: list[dict] = []
warnings: list[str] = []
used: set[str] = set()
grouped: dict[str, list[dict]] = {}
for entry in entries:
grouped.setdefault(entry["source"], []).append(entry)
for source, candidates in grouped.items():
meanings = {entry["meaning"] for entry in candidates}
if len(meanings) != 1:
warnings.append(
f"source {source!r}: ignored ambiguous scoped mappings "
f"({len(meanings)} meanings)"
)
continue
entry = candidates[0]
base = identifier_from_meaning(entry["meaning"])
replacement = base
collision = False
if replacement in used:
collision = True
suffix = re.sub(r"[^A-Za-z0-9_]", "_", source)
if not suffix.strip("_"):
suffix = "_".join(f"u{ord(char):04X}" for char in source)
replacement = f"{base}__from_{suffix}"
number = 2
while replacement in used:
replacement = f"{base}__from_{suffix}_{number}"
number += 1
used.add(replacement)
replacements[source] = replacement
report.append({
**entry,
"replacement": replacement,
"sanitized": replacement != entry["meaning"],
"name_collision": collision,
})
return replacements, report, warnings
def _consume_quoted(text: str, start: int, quote: str) -> int:
"""Return the index after a C# string/character/raw-string literal."""
quotes = 0
while start + quotes < len(text) and text[start + quotes] == quote:
quotes += 1
if quote == '"' and quotes >= 3:
end_marker = quote * quotes
end = text.find(end_marker, start + quotes)
return len(text) if end < 0 else end + quotes
index = start + 1
verbatim = start > 0 and text[start - 1] == "@"
while index < len(text):
if verbatim and quote == '"' and text.startswith('""', index):
index += 2
elif text[index] == quote:
return index + 1
elif not verbatim and text[index] == "\\":
index += 2
else:
index += 1
return len(text)
def replace_csharp_identifiers(text: str, replacements: dict[str, str]) -> tuple[str, int]:
"""Replace code identifiers only; comments and literal payloads stay exact."""
output: list[str] = []
index = changed = 0
length = len(text)
while index < length:
if text.startswith("//", index):
end = text.find("\n", index)
end = length if end < 0 else end
output.append(text[index:end])
index = end
elif text.startswith("/*", index):
end = text.find("*/", index + 2)
end = length if end < 0 else end + 2
output.append(text[index:end])
index = end
elif text[index] in "\"'":
end = _consume_quoted(text, index, text[index])
output.append(text[index:end])
index = end
elif is_identifier_start(text[index]):
end = index + 1
while end < length and is_identifier_continue(text[end]):
end += 1
token = text[index:end]
replacement = replacements.get(token, token)
output.append(replacement)
changed += replacement != token
index = end
else:
output.append(text[index])
index += 1
return "".join(output), changed
def read_csharp(path: Path) -> tuple[str, str] | None:
data = path.read_bytes()
if data.startswith(b"\xff\xfe"):
return data[2:].decode("utf-16-le"), "utf-16-le"
if data.startswith(b"\xfe\xff"):
return data[2:].decode("utf-16-be"), "utf-16-be"
try:
return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
except UnicodeDecodeError:
return None
def write_csharp(path: Path, text: str, encoding: str) -> None:
if encoding == "utf-16-le":
path.write_bytes(b"\xff\xfe" + text.encode(encoding))
elif encoding == "utf-16-be":
path.write_bytes(b"\xfe\xff" + text.encode(encoding))
elif encoding == "utf-8-sig":
path.write_bytes(text.encode(encoding))
else:
path.write_text(text, encoding="utf-8", newline="")
def destination_for(relative: Path, replacements: dict[str, str], occupied: set[Path]) -> Path:
if relative.suffix.casefold() != ".cs":
return relative
stem = replacements.get(relative.stem, relative.stem)
candidate = relative.with_name(stem + relative.suffix)
if candidate not in occupied:
return candidate
number = 2
while True:
candidate = relative.with_name(f"{stem}__file_{number}{relative.suffix}")
if candidate not in occupied:
return candidate
number += 1
def deobfuscate(source: Path, mapping: Path, output: Path) -> dict:
source, output = require_distinct_trees(source, output)
mapping = mapping.resolve()
if not mapping.is_file():
raise ValueError(f"mapping is not a file: {mapping}")
entries, warnings = parse_mapping(mapping)
replacements, mapping_report, replacement_warnings = build_replacements(entries)
warnings.extend(replacement_warnings)
stage = prepare_stage(output)
files: list[dict] = []
occupied: set[Path] = set()
try:
for input_path in sorted(path for path in source.rglob("*") if path.is_file()):
relative = input_path.relative_to(source)
destination = destination_for(relative, replacements, occupied)
occupied.add(destination)
destination_path = stage / destination
destination_path.parent.mkdir(parents=True, exist_ok=True)
record = {"source": relative.as_posix(), "output": destination.as_posix()}
if input_path.suffix.casefold() == ".cs":
decoded = read_csharp(input_path)
if decoded is None:
shutil.copy2(input_path, destination_path)
record.update({"action": "copied", "reason": "unsupported text encoding"})
warnings.append(f"{relative}: copied without replacement (unsupported encoding)")
else:
text, encoding = decoded
translated, changed = replace_csharp_identifiers(text, replacements)
write_csharp(destination_path, translated, encoding)
record.update({"action": "translated", "identifier_replacements": changed})
else:
shutil.copy2(input_path, destination_path)
record["action"] = "copied"
files.append(record)
manifest = {
"tool": TOOL,
"source": str(source),
"mapping": str(mapping),
"mapping_entries": mapping_report,
"files": files,
"warnings": warnings,
"statistics": {
"mapping_entries": len(mapping_report),
"files": len(files),
"csharp_files": sum(item["source"].casefold().endswith(".cs") for item in files),
"identifier_replacements": sum(item.get("identifier_replacements", 0) for item in files),
},
"limitations": [
"Only C# identifier tokens are changed; comments and literal contents are preserved.",
"Qualified mapping values become their terminal identifier component.",
"This is a searchable mirror, not a promise that the transformed source compiles.",
],
}
(stage / MANIFEST).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
publish_stage(stage, output)
return manifest
except Exception:
shutil.rmtree(stage, ignore_errors=True)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path, help="client Assembly-CSharp directory")
parser.add_argument("--mapping", required=True, type=Path, help="ObfuscationTranslation file")
parser.add_argument("--output", required=True, type=Path, help="mirror directory outside --source")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
manifest = deobfuscate(args.source, args.mapping, args.output)
except (OSError, ValueError, UnicodeError) as exc:
raise SystemExit(f"deobfuscate_client_source: {exc}")
stats = manifest["statistics"]
print(f"wrote {args.output.resolve()} ({stats['files']} files, {stats['identifier_replacements']} replacements)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""Local-only browser tool for adding ItemDBInfo-compatible GameData items to mail.
This program deliberately is not part of bd2server. It reads the selected
2.34.13 GameData archive and writes a complete replacement mail seed using an
atomic rename. Start bd2server once with --mail-seed pointing at the same
--output; subsequent grants are hot-loaded by the normal /MailInfo request.
It never reads or changes data/state.
Example:
python tools/python/dev_mail_grant.py serve `
--game-data E:\\bd2\\dl\\GameData --game-data-version 20260910162539 `
--mail-seed go\\seed\\v2_34_13\\mail.json --output data\\dev\\mail-grants.json
"""
from __future__ import annotations
import argparse
import html
import json
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
import threading
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
# gamedata_db is the repository's reviewed, read-only GameData decryptor.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from gamedata_db import read_database, walk_wire # noqa: E402
VERSION = "2.34.13"
MAX_INT32 = (1 << 31) - 1
# These are the local server's ItemDBInfo-backed ElementTypes. The mapping is
# checked against 2.34.13 DataManager.GetItemInfo; characters, equipment,
# costumes, and trophies use separate RewardDBInfoBundle fields and are not
# falsely offered by this tool.
ITEM_SOURCES = (
("ResourceTable", 8, 4, 7, "资源"),
("FoodTable", 5, 7, 10, "料理"),
("CookingTable", 7, 3, 11, "烹饪配方"),
("RandomBoxTable", 9, 4, 7, "随机箱"),
("QuestItemTable", 13, 2, 5, "任务物品"),
("UseItemTable", 14, 3, 6, "使用物品"),
("CollectionTable", 17, 3, 7, "收藏品"),
("MyRoomItemTable", 27, 7, 17, "我的房间物品"),
("InstantUseItemTable", 29, 1, 3, "即时使用物品"),
)
def _varint(value: Any) -> int:
if not isinstance(value, int) or value < 0:
raise ValueError("expected a non-negative protobuf varint")
return value
def fields(proto: bytes) -> dict[int, list[Any]]:
result: dict[int, list[Any]] = {}
for number, wire_type, value in walk_wire(proto):
if wire_type != 0 and wire_type != 2:
continue
result.setdefault(number, []).append(value)
return result
def first_varint(values: dict[int, list[Any]], number: int) -> int:
entries = values.get(number, [])
if len(entries) != 1:
return 0
return _varint(entries[0])
def first_text(values: dict[int, list[Any]], number: int) -> str:
entries = values.get(number, [])
if len(entries) != 1 or not isinstance(entries[0], bytes):
return ""
return entries[0].decode("utf-8")
def packed_varints(values: dict[int, list[Any]], number: int) -> list[int]:
"""Decode proto3 packed/repeated uint fields without guessing their shape."""
result: list[int] = []
for entry in values.get(number, []):
if isinstance(entry, int):
result.append(_varint(entry))
continue
if not isinstance(entry, bytes):
raise ValueError(f"field {number} is not a protobuf varint")
offset = 0
while offset < len(entry):
value = 0
for shift in range(0, 70, 7):
if offset >= len(entry):
raise ValueError(f"truncated packed protobuf field {number}")
byte = entry[offset]
offset += 1
value |= (byte & 0x7f) << shift
if byte < 0x80:
result.append(value)
break
else:
raise ValueError(f"oversized packed protobuf field {number}")
return result
def open_readonly_database(root: Path, version: str) -> tuple[sqlite3.Connection, Path]:
"""Open the current common database in a private read-only SQLite file."""
plain = read_database(root, version, "quest")
handle = tempfile.NamedTemporaryFile(prefix="bd2-dev-mail-", suffix=".db", delete=False)
path = Path(handle.name)
try:
handle.write(plain)
handle.flush()
finally:
handle.close()
try:
connection = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)
connection.execute("PRAGMA query_only=ON")
return connection, path
except Exception:
path.unlink(missing_ok=True)
raise
def _usable_display_name(value: str) -> bool:
return bool(value) and not value.startswith("<未找到本地化文本 #") and value not in {"(不使用)", "(不使用)"}
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.
if item["element_type"] == 9:
return False
# ResourceTable Type=2 rows are field-object presentation sentinels, not
# inventory materials. 90045/90046 even point at costume IDs for their
# field popup and crash ItemInfoPopupUI when presented as normal resources.
if item.get("source_table") == "ResourceTable" and item.get("resource_type") == 2:
return False
return _usable_display_name(item["name"])
def map_fixed_boxes_to_direct_items(
items: list[dict[str, Any]],
fixed_boxes: dict[int, tuple[int, int, int]],
product_aliases: dict[int, dict[str, int]],
) -> list[dict[str, Any]]:
"""Hide deterministic boxes and expose their contained ItemDBInfo directly.
The quantity entered in the development form is the final material count;
the source box multiplier is deliberately informational and is not applied.
"""
by_key = {(item["element_type"], item["id"]): item for item in items}
target_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)
target = by_key.get(target_key)
box = by_key.get((9, box_id))
if target is None or box is None:
continue
source_boxes.setdefault(target_key, []).append((box_id, reward_count))
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():
target = by_key[target_key]
canonical = max(aliases, key=lambda value: (aliases[value], -len(value), value)) if aliases else ""
if not _usable_display_name(target["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]
preview = "、".join(str(box_id) for box_id, _ in boxes[:4])
if len(boxes) > 4:
preview += f" 等 {len(boxes)} 个"
details = ["开发邮件直接发放此物品(无需开箱)"]
if target["aliases"]:
details.append("商品名:" + canonical)
details.append("固定箱映射:" + preview)
target["details"] = ";".join(details)
return [item for item in items if _safe_direct_mail_item(item)]
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
items: list[dict[str, Any]] = []
for table, element_type, id_field, name_field, category in ITEM_SOURCES:
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)
name_text_id = first_varint(decoded, name_field)
items.append({
"id": item_id,
"element_type": element_type,
"name": names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
"category": category,
"source_table": table,
"name_text_id": name_text_id,
"resource_type": first_varint(decoded, 13) if table == "ResourceTable" else None,
})
# Currency is not an ItemDBInfo row. MailOpen and RewardDBInfoBundle
# represent it as type=Gold(4), id=0 and the entered count, which the
# wallet atomically adds to the existing balance.
items.append({
"id": 0,
"element_type": 4,
"name": "金币",
"category": "货币(直接入账)",
"source_table": "Currency",
"name_text_id": 0,
"resource_type": None,
"details": "邮件领取后直接叠加到金币余额,不生成背包物品或随机箱",
})
if not items:
raise ValueError("可领取的 ItemDBInfo 静态表为空")
# A RandomBox is itself a legitimate ItemDBInfo attachment, but its
# visible name is often a generic box name while players search for a
# guaranteed material inside it (for example 女神之泪). Follow only
# one-entry RewardGroupTable definitions: that is a real, deterministic
# GameData relationship, not an invented unpack result. Cash-product
# display names are aliases as well, so names such as 光明圣石 which are
# used by a product but not by the ResourceTable row remain searchable.
by_key = {(item["element_type"], item["id"]): item for item in items}
groups: dict[int, tuple[int, int, int] | None] = {}
for group_id, proto in connection.execute("SELECT id, ProtoBuf FROM RewardGroupTable"):
decoded = fields(proto)
reward_ids = packed_varints(decoded, 5)
reward_types = packed_varints(decoded, 6)
reward_counts = packed_varints(decoded, 4)
if len(reward_ids) != 1 or len(reward_types) != 1 or len(reward_counts) != 1:
groups[int(group_id)] = None
continue
reward_id, reward_type, reward_count = reward_ids[0], reward_types[0], reward_counts[0]
valid_id = reward_id == 0 if reward_type in {3, 4} else reward_id != 0
groups[int(group_id)] = (reward_type, reward_id, reward_count) if reward_type and valid_id and reward_count else None
fixed_boxes: dict[int, tuple[int, int, int]] = {}
for box_id, proto in connection.execute("SELECT id, ProtoBuf FROM RandomBoxTable"):
decoded = fields(proto)
reward_group_id = first_varint(decoded, 9)
reward = groups.get(reward_group_id)
if reward is None:
continue
reward_type, reward_id, reward_count = reward
contained = by_key.get((reward_type, reward_id))
if contained is None:
continue
fixed_boxes[int(box_id)] = (reward_type, reward_id, reward_count)
product_aliases: dict[int, dict[str, int]] = {}
for (proto,) in connection.execute("SELECT ProtoBuf FROM CashProductTable"):
decoded = fields(proto)
box_id = first_varint(decoded, 14)
product_text_id = first_varint(decoded, 11)
product_name = names.get(product_text_id, "")
if box_id and product_name and box_id in fixed_boxes:
aliases = product_aliases.setdefault(box_id, {})
aliases[product_name] = aliases.get(product_name, 0) + 1
# A deterministic type-9 wrapper is unsuitable for this developer
# mailbox: the user wants the material count they entered, immediately
# usable after MailOpen. Replace such choices with their authoritative
# contained ItemDBInfo rather than requiring /UseRandomBox afterwards.
return map_fixed_boxes_to_direct_items(items, fixed_boxes, product_aliases)
finally:
connection.close()
temporary.unlink(missing_ok=True)
def load_seed(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise ValueError(f"无法读取邮件种子 {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"邮件种子不是 JSON: {exc}") from exc
if value.get("version") != VERSION or not isinstance(value.get("mails"), list):
raise ValueError(f"邮件种子必须是 version={VERSION} 且含 mails 数组")
ids: set[int] = set()
for entry in value["mails"]:
mail_id = entry.get("mail_id")
if not isinstance(mail_id, int) or mail_id <= 0 or mail_id in ids:
raise ValueError("邮件种子含零、非整数或重复的 mail_id")
ids.add(mail_id)
return value
def normalise_seed(seed: dict[str, Any]) -> dict[str, Any]:
"""Make the server sentinel fields agree with the complete mail list."""
result = dict(seed)
result["version"] = VERSION
result["mails"] = list(seed["mails"])
result["mail_count"] = len(result["mails"]) + 1
result["max_mail_id"] = max((entry["mail_id"] for entry in result["mails"]), default=0)
return result
def atomic_json(path: Path, value: dict[str, Any]) -> None:
path = path.resolve()
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
class MailGrantStore:
def __init__(self, source: Path, output: Path, items: list[dict[str, Any]], expires_days: int):
self.source = source.resolve()
self.output = output.resolve()
self.items = items
self.item_keys = {(item["element_type"], item["id"]) for item in items}
self.expires_days = expires_days
self.lock = threading.Lock()
self.seed = normalise_seed(load_seed(self.output if self.output.exists() else self.source))
# Write the complete baseline immediately. The game server can
# therefore begin watching --output before the first browser grant.
if not self.output.exists():
atomic_json(self.output, self.seed)
def grant(self, payload: Any) -> dict[str, Any]:
with self.lock:
return self._grant_locked(payload)
def _grant_locked(self, payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("请求必须是 JSON 对象")
item_id = payload.get("item_id")
element_type = payload.get("element_type")
count = payload.get("count")
if not isinstance(item_id, int) or not isinstance(element_type, int) or (element_type, item_id) not in self.item_keys:
raise ValueError("element_type 与 item_id 必须是当前直接邮件列表中的安全组合")
if not isinstance(count, int) or not 1 <= count <= MAX_INT32:
raise ValueError(f"数量必须是 1 到 {MAX_INT32}")
title = payload.get("title", "开发测试物品")
body = payload.get("body", "由本地开发邮件工具发放。")
if not isinstance(title, str) or not isinstance(body, str):
raise ValueError("标题和正文必须是字符串")
title, body = title.strip(), body.strip()
if not title or len(title) > 500 or len(body) > 5000:
raise ValueError("标题不能为空且不超过 500 字符;正文不超过 5000 字符")
current_ids = {entry["mail_id"] for entry in self.seed["mails"]}
mail_id = max(current_ids, default=13_000_000_000) + 1
# MailDBInfo's InvenIndex is int64 in the 2.34.13 client descriptor.
if mail_id > (1 << 63) - 1:
raise ValueError("没有可用的正 int64 邮件 ID")
now = int(time.time() * 1000)
expires = now + self.expires_days * 24 * 60 * 60 * 1000
entry = {
"mail_id": mail_id,
"mail_type": 2,
"title": title,
"body": body,
"expires_at": expires,
"reward_types": [element_type],
"reward_ids": [item_id],
"reward_counts": [count],
"sent_at": now,
}
next_seed = normalise_seed({**self.seed, "mails": [*self.seed["mails"], entry]})
atomic_json(self.output, next_seed)
self.seed = next_seed
return {"mail": entry, "output": str(self.output), "restart_required": False}
PAGE = """<!doctype html><meta charset=utf-8><title>BD2 开发邮件发放</title>
<style>body{font:14px system-ui;max-width:1060px;margin:2rem auto;padding:0 1rem}input,textarea,button{font:inherit;padding:.4rem}input{width:100%}table{border-collapse:collapse;width:100%;margin:0}th,td{border:1px solid #ccc;padding:.4rem;text-align:left}tr:hover{background:#f5f5f5}#status{white-space:pre-wrap;margin:1rem 0}.small{color:#555}.pick{white-space:nowrap}#item-picker{margin:1rem 0;border:1px solid #ccc;border-radius:.35rem;padding:.55rem}#item-picker summary{cursor:pointer;font-weight:600}#item-picker[open] summary{margin-bottom:.75rem}.item-list{max-height:min(40vh,28rem);overflow:auto;border:1px solid #ccc;margin-top:1rem}.item-list thead th{position:sticky;top:0;background:#fff}.item-list table{min-width:760px}</style>
<h1>BD2 开发邮件发放</h1><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。金币直接叠加到钱包。提交会原子写入临时邮件种子;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
<details id=item-picker><summary>选择开发测试物品 <span id=count class=small></span></summary><label>搜索(ID、名称、类别、固定箱映射)<input id=q></label><div class=item-list><table><thead><tr><th>ID</th><th>类型</th><th>名称</th><th>类别/内容</th><th></th></tr></thead><tbody id=items></tbody></table></div></details>
<h2>发放一个附件</h2><form id=form><label>物品 ID<input id=item_id required readonly></label><input id=element_type required readonly type=hidden><label>数量(1–2147483647)<input id=quantity type=number min=1 max=2147483647 value=1 required></label><label>邮件标题<input id=title value="开发测试物品" required maxlength=500></label><label>正文<textarea id=body maxlength=5000>由本地开发邮件工具发放。</textarea></label><p><button>写入临时邮件种子</button></p></form><pre id=status></pre>
<script>let all=[];const $=id=>document.getElementById(id);function render(){let q=$('q').value.toLowerCase();let matches=all.filter(x=>(x.id+' '+x.element_type+' '+x.name+' '+x.category+' '+(x.details||'')).toLowerCase().includes(q));let rows=matches.slice(0,500);$('count').textContent=`(匹配 ${matches.length} / ${all.length} 项;显示前 ${rows.length} 项)`; $('items').innerHTML=rows.map(x=>`<tr><td>${x.id}</td><td>${x.element_type}</td><td>${esc(x.name)}</td><td>${esc(x.category+(x.details?':'+x.details:''))}</td><td class=pick><button onclick="pick(${x.element_type},${x.id})">选择</button></td></tr>`).join('')}function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}function pick(t,id){$('item_id').value=id;$('element_type').value=t;$('item-picker').open=false;$('quantity').focus();$('form').scrollIntoView({block:'nearest',behavior:'smooth'})}$('q').oninput=render;$('form').onsubmit=async e=>{e.preventDefault();let r=await fetch('/api/grants',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({item_id:+$('item_id').value,element_type:+$('element_type').value,count:+$('quantity').value,title:$('title').value,body:$('body').value})});let x=await r.json();$('status').textContent=r.ok?`已写入邮件 #${x.mail.mail_id}。\n重新打开或刷新游戏邮箱即可看到并领取;服务端无需重启。\n货币会直接叠加,固定箱映射会直接发放内容物。\n热载文件:${x.output}`:x.error};fetch('/api/items').then(r=>r.json()).then(x=>{all=x.items;render()});</script>"""
class Handler(BaseHTTPRequestHandler):
store: MailGrantStore
def do_GET(self) -> None: # noqa: N802
if self.path == "/":
self.reply(HTTPStatus.OK, "text/html; charset=utf-8", PAGE.encode())
elif self.path == "/api/items":
self.reply_json(HTTPStatus.OK, {"items": self.store.items})
else:
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
if self.path != "/api/grants":
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 32_768:
raise ValueError("请求体大小无效")
result = self.store.grant(json.loads(self.rfile.read(length)))
self.reply_json(HTTPStatus.CREATED, result)
except (OSError, ValueError, json.JSONDecodeError) as exc:
self.reply_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
def reply(self, status: HTTPStatus, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def reply_json(self, status: HTTPStatus, value: dict[str, Any]) -> None:
self.reply(status, "application/json; charset=utf-8", json.dumps(value, ensure_ascii=False).encode())
def log_message(self, format: str, *args: object) -> None:
print("dev-mail:", format % args)
def serve(args: argparse.Namespace) -> int:
if args.expires_days < 1 or args.expires_days > 3650:
raise ValueError("--expires-days 必须是 1 到 3650")
items = load_items(args.game_data, args.game_data_version)
store = MailGrantStore(args.mail_seed, args.output, items, args.expires_days)
Handler.store = store
server = ThreadingHTTPServer((args.listen_host, args.listen_port), Handler)
print(f"已读取 {len(items)} 个可由 ItemDBInfo 领取的 GameData 物品。")
print(f"浏览器打开:http://{args.listen_host}:{args.listen_port}/")
print(f"临时邮件种子:{store.output}")
print("此服务不修改 data/state;bd2server 指向该 seed 后,每次 /MailInfo 自动热载。")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n开发邮件工具已停止。")
finally:
server.server_close()
return 0
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
commands = result.add_subparsers(dest="command", required=True)
command = commands.add_parser("serve", help="start the loopback browser UI")
command.add_argument("--game-data", type=Path, required=True, help="GameData root")
command.add_argument("--game-data-version", required=True, help="validated GameData version")
command.add_argument("--mail-seed", type=Path, required=True, help="base mail seed; read only")
command.add_argument("--output", type=Path, required=True, help="generated development mail seed")
command.add_argument("--listen-host", default="127.0.0.1", help="loopback host (default: 127.0.0.1)")
command.add_argument("--listen-port", default=8765, type=int, help="loopback port (default: 8765)")
command.add_argument("--expires-days", default=365, type=int, help="development mail validity (default: 365)")
command.set_defaults(run=serve)
return result
def main() -> int:
args = parser().parse_args()
try:
if args.listen_host not in {"127.0.0.1", "localhost", "::1"}:
raise ValueError("开发邮件服务只允许监听本机回环地址")
return args.run(args)
except (OSError, ValueError, sqlite3.Error) as exc:
print(f"dev_mail_grant: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""Reconstruct .proto files from descriptors embedded in generated C#.
The client does not ship original .proto sources. Each *Reflection.cs embeds a
serialized FileDescriptorProto; this tool extracts those descriptors, writes a
lossless FileDescriptorSet, and renders readable .proto source files.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import shutil
import tempfile
from google.protobuf import descriptor_pb2
TOOL = "bd2.extract_client_proto"
MANIFEST = ".bd2-proto-extract-manifest.json"
DESCRIPTOR_SET = "client-descriptors.pb"
# Protobuf C# generator uses both string.Concat(new string[] {...}) and a
# direct literal/ordinary string concatenation depending on generator version.
REFLECTION_RE = re.compile(
r"Convert\.FromBase64String\s*\((?P<body>.*?)\)\s*,\s*new\s+FileDescriptor",
re.DOTALL,
)
STRING_RE = re.compile(r'"([A-Za-z0-9+/=\s]*)"')
SCALARS = {
descriptor_pb2.FieldDescriptorProto.TYPE_DOUBLE: "double",
descriptor_pb2.FieldDescriptorProto.TYPE_FLOAT: "float",
descriptor_pb2.FieldDescriptorProto.TYPE_INT64: "int64",
descriptor_pb2.FieldDescriptorProto.TYPE_UINT64: "uint64",
descriptor_pb2.FieldDescriptorProto.TYPE_INT32: "int32",
descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64: "fixed64",
descriptor_pb2.FieldDescriptorProto.TYPE_FIXED32: "fixed32",
descriptor_pb2.FieldDescriptorProto.TYPE_BOOL: "bool",
descriptor_pb2.FieldDescriptorProto.TYPE_STRING: "string",
descriptor_pb2.FieldDescriptorProto.TYPE_GROUP: "group",
descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE: "message",
descriptor_pb2.FieldDescriptorProto.TYPE_BYTES: "bytes",
descriptor_pb2.FieldDescriptorProto.TYPE_UINT32: "uint32",
descriptor_pb2.FieldDescriptorProto.TYPE_ENUM: "enum",
descriptor_pb2.FieldDescriptorProto.TYPE_SFIXED32: "sfixed32",
descriptor_pb2.FieldDescriptorProto.TYPE_SFIXED64: "sfixed64",
descriptor_pb2.FieldDescriptorProto.TYPE_SINT32: "sint32",
descriptor_pb2.FieldDescriptorProto.TYPE_SINT64: "sint64",
}
def is_within(child: Path, parent: Path) -> bool:
try:
child.relative_to(parent)
return True
except ValueError:
return False
def validate_paths(source: Path, output: Path) -> tuple[Path, Path]:
source, output = source.resolve(), output.resolve()
if not source.is_dir():
raise ValueError(f"source is not a directory: {source}")
if source == output or is_within(output, source) or is_within(source, output):
raise ValueError("output must be outside, and not contain, source")
return source, output
def is_managed(output: Path) -> bool:
manifest = output / MANIFEST
if not manifest.is_file():
return False
try:
return json.loads(manifest.read_text(encoding="utf-8")).get("tool") == TOOL
except (OSError, json.JSONDecodeError):
return False
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))
def extract_descriptor(path: Path) -> descriptor_pb2.FileDescriptorProto | None:
text = path.read_text(encoding="utf-8-sig")
match = REFLECTION_RE.search(text)
if match is None:
return None
encoded = "".join(piece.group(1) for piece in STRING_RE.finditer(match.group("body")))
if not encoded:
raise ValueError(f"reflection contains no descriptor Base64: {path}")
descriptor = descriptor_pb2.FileDescriptorProto()
descriptor.ParseFromString(base64.b64decode(encoded, validate=True))
if not descriptor.name:
raise ValueError(f"descriptor has no source name: {path}")
return descriptor
def safe_descriptor_path(name: str) -> Path:
pure = PurePosixPath(name.replace("\\", "/"))
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
raise ValueError(f"unsafe descriptor path: {name!r}")
return Path(*pure.parts)
def quoted(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def type_name(field: descriptor_pb2.FieldDescriptorProto) -> str:
scalar = SCALARS.get(field.type)
if scalar not in {"message", "enum", "group"}:
if scalar is None:
raise ValueError(f"unknown protobuf field type {field.type}")
return scalar
return field.type_name or scalar
def option_value(value) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return quoted(value)
if hasattr(value, "name"):
return value.name
return str(value)
def rendered_options(options, allowed: set[str] | None = None) -> tuple[list[str], bool]:
result = []
known = options.__class__()
for field, value in options.ListFields():
if field.is_extension or field.name in {"uninterpreted_option", "features"}:
continue
if allowed is not None and field.name not in allowed:
continue
if field.is_repeated or field.message_type is not None:
continue
setattr(known, field.name, value)
result.append(f"{field.name} = {option_value(value)}")
return result, known.SerializeToString() != options.SerializeToString()
def inline_options(options, allowed: set[str] | None = None) -> tuple[str, bool]:
values, incomplete = rendered_options(options, allowed)
return (" [" + ", ".join(values) + "]" if values else ""), incomplete
def render_enum(enum, indent: str, warnings: list[str], path: str) -> list[str]:
lines = [f"{indent}enum {enum.name} {{"]
opts, incomplete = rendered_options(enum.options)
for option in opts:
lines.append(f"{indent} option {option};")
if incomplete:
warnings.append(f"{path}: enum options retained only in descriptor set")
for reserved in enum.reserved_range:
end = reserved.end - 1
lines.append(f"{indent} reserved {reserved.start}{' to ' + str(end) if end != reserved.start else ''};")
if enum.reserved_name:
lines.append(f"{indent} reserved " + ", ".join(quoted(v) for v in enum.reserved_name) + ";")
for value in enum.value:
options, missing = inline_options(value.options)
if missing:
warnings.append(f"{path}.{value.name}: enum value options retained only in descriptor set")
lines.append(f"{indent} {value.name} = {value.number}{options};")
lines.append(f"{indent}}}")
return lines
def map_entries(message) -> dict[str, object]:
return {nested.name: nested for nested in message.nested_type if nested.options.map_entry}
def render_field(field, syntax: str, indent: str, maps: dict[str, object], warnings: list[str], path: str) -> str:
target = field.type_name.rsplit(".", 1)[-1]
if field.label == field.LABEL_REPEATED and target in maps:
entry = maps[target]
if len(entry.field) == 2:
declaration = f"map<{type_name(entry.field[0])}, {type_name(entry.field[1])}>"
else:
declaration = f"repeated {type_name(field)}"
else:
label = ""
if field.label == field.LABEL_REPEATED:
label = "repeated "
elif syntax != "proto3" and field.label == field.LABEL_REQUIRED:
label = "required "
elif syntax != "proto3" or field.proto3_optional:
label = "optional "
declaration = label + type_name(field)
allowed = {"ctype", "packed", "jstype", "lazy", "deprecated", "weak", "unverified_lazy", "debug_redact", "retention"}
values, missing = rendered_options(field.options, allowed)
if field.default_value:
values.insert(0, f"default = {quoted(field.default_value) if field.type in (field.TYPE_STRING, field.TYPE_BYTES) else field.default_value}")
if field.json_name and field.json_name != field.name:
values.append(f"json_name = {quoted(field.json_name)}")
if missing:
warnings.append(f"{path}: field options retained only in descriptor set")
suffix = " [" + ", ".join(values) + "]" if values else ""
return f"{indent}{declaration} {field.name} = {field.number}{suffix};"
def render_message(message, syntax: str, indent: str, warnings: list[str], path: str) -> list[str]:
lines = [f"{indent}message {message.name} {{"]
opts, incomplete = rendered_options(message.options, {"message_set_wire_format", "no_standard_descriptor_accessor", "deprecated"})
for option in opts:
lines.append(f"{indent} option {option};")
if incomplete and not message.options.map_entry:
warnings.append(f"{path}: message options retained only in descriptor set")
for reserved in message.reserved_range:
end = reserved.end - 1
lines.append(f"{indent} reserved {reserved.start}{' to ' + str(end) if end != reserved.start else ''};")
if message.reserved_name:
lines.append(f"{indent} reserved " + ", ".join(quoted(v) for v in message.reserved_name) + ";")
for extension in message.extension_range:
end = "max" if extension.end >= 536870912 else str(extension.end - 1)
lines.append(f"{indent} extensions {extension.start} to {end};")
maps = map_entries(message)
synthetic = {field.oneof_index for field in message.field if field.proto3_optional}
regular_oneofs = {index for index in range(len(message.oneof_decl)) if index not in synthetic}
for field in message.field:
if field.HasField("oneof_index") and field.oneof_index in regular_oneofs:
continue
lines.append(render_field(field, syntax, indent + " ", maps, warnings, f"{path}.{field.name}"))
for index in sorted(regular_oneofs):
oneof = message.oneof_decl[index]
lines.append(f"{indent} oneof {oneof.name} {{")
for field in message.field:
if field.HasField("oneof_index") and field.oneof_index == index:
copy = descriptor_pb2.FieldDescriptorProto()
copy.CopyFrom(field)
copy.ClearField("oneof_index")
copy.label = copy.LABEL_OPTIONAL
lines.append(render_field(copy, "proto3", indent + " ", maps, warnings, f"{path}.{field.name}"))
lines.append(f"{indent} }}")
for enum in message.enum_type:
lines.extend(render_enum(enum, indent + " ", warnings, f"{path}.{enum.name}"))
for nested in message.nested_type:
if not nested.options.map_entry:
lines.extend(render_message(nested, syntax, indent + " ", warnings, f"{path}.{nested.name}"))
lines.append(f"{indent}}}")
return lines
def render_extensions(fields, syntax: str, warnings: list[str], path: str) -> list[str]:
grouped: dict[str, list[object]] = {}
for field in fields:
grouped.setdefault(field.extendee, []).append(field)
lines = []
for extendee, entries in grouped.items():
lines.append(f"extend {extendee} {{")
for field in entries:
lines.append(render_field(field, syntax, " ", {}, warnings, f"{path}.{field.name}"))
lines.append("}")
return lines
def render_file(descriptor: descriptor_pb2.FileDescriptorProto) -> tuple[str, list[str]]:
warnings: list[str] = []
syntax = descriptor.syntax or "proto2"
lines = [f'syntax = "{syntax}";', ""]
if descriptor.package:
lines += [f"package {descriptor.package};", ""]
public = set(descriptor.public_dependency)
weak = set(descriptor.weak_dependency)
for index, dependency in enumerate(descriptor.dependency):
qualifier = "public " if index in public else "weak " if index in weak else ""
lines.append(f"import {qualifier}{quoted(dependency)};")
if descriptor.dependency:
lines.append("")
options, incomplete = rendered_options(descriptor.options)
for option in options:
lines.append(f"option {option};")
if incomplete:
warnings.append(f"{descriptor.name}: file options retained only in descriptor set")
if options:
lines.append("")
for enum in descriptor.enum_type:
lines.extend(render_enum(enum, "", warnings, f"{descriptor.name}:{enum.name}"))
lines.append("")
for message in descriptor.message_type:
lines.extend(render_message(message, syntax, "", warnings, f"{descriptor.name}:{message.name}"))
lines.append("")
lines.extend(render_extensions(descriptor.extension, syntax, warnings, descriptor.name))
if descriptor.extension:
lines.append("")
for service in descriptor.service:
lines.append(f"service {service.name} {{")
for method in service.method:
client = "stream " if method.client_streaming else ""
server = "stream " if method.server_streaming else ""
lines.append(f" rpc {method.name} ({client}{method.input_type}) returns ({server}{method.output_type});")
lines += ["}", ""]
return "\n".join(lines).rstrip() + "\n", warnings
def reconstruct(source: Path, output: Path) -> dict:
source, output = validate_paths(source, output)
found: dict[str, tuple[descriptor_pb2.FileDescriptorProto, str]] = {}
scan_warnings: list[str] = []
for reflection in sorted(source.rglob("*Reflection.cs")):
if not reflection.is_file() or "proto" not in {part.casefold() for part in reflection.parts}:
continue
descriptor = extract_descriptor(reflection)
if descriptor is None:
scan_warnings.append(f"{reflection.relative_to(source).as_posix()}: no embedded descriptor")
continue
relative = reflection.relative_to(source).as_posix()
existing = found.get(descriptor.name)
if existing is not None:
if existing[0].SerializeToString() != descriptor.SerializeToString():
raise ValueError(f"conflicting descriptors named {descriptor.name!r}")
scan_warnings.append(f"{relative}: duplicate descriptor also in {existing[1]}")
continue
found[descriptor.name] = (descriptor, relative)
if not found:
raise ValueError("no embedded FileDescriptorProto values found")
stage = stage_for(output)
try:
descriptor_set = descriptor_pb2.FileDescriptorSet()
records = []
warnings = list(scan_warnings)
for name in sorted(found):
descriptor, reflection = found[name]
descriptor_set.file.add().CopyFrom(descriptor)
relative = safe_descriptor_path(name)
target = stage / relative
target.parent.mkdir(parents=True, exist_ok=True)
rendered, file_warnings = render_file(descriptor)
target.write_text(rendered, encoding="utf-8", newline="\n")
warnings.extend(file_warnings)
records.append({
"path": relative.as_posix(),
"package": descriptor.package,
"syntax": descriptor.syntax or "proto2",
"source_reflection": reflection,
"messages": len(descriptor.message_type),
"enums": len(descriptor.enum_type),
"dependencies": list(descriptor.dependency),
"descriptor_sha256": hashlib.sha256(descriptor.SerializeToString()).hexdigest(),
"proto_sha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(),
})
descriptor_bytes = descriptor_set.SerializeToString()
(stage / DESCRIPTOR_SET).write_bytes(descriptor_bytes)
manifest = {
"tool": TOOL,
"source": str(source),
"descriptor_set": DESCRIPTOR_SET,
"descriptor_set_sha256": hashlib.sha256(descriptor_bytes).hexdigest(),
"files": records,
"warnings": warnings,
"statistics": {
"proto_files": len(records),
"messages": sum(item["messages"] for item in records),
"enums": sum(item["enums"] for item in records),
"warnings": len(warnings),
},
"limitations": [
"Generated source comments are unavailable in FileDescriptorProto.",
"Options not representable by this renderer remain losslessly available in client-descriptors.pb.",
],
}
(stage / MANIFEST).write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if output.exists():
shutil.rmtree(output)
stage.replace(output)
return manifest
except Exception:
shutil.rmtree(stage, ignore_errors=True)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path, help="client Assembly-CSharp directory")
parser.add_argument("--output", required=True, type=Path, help="reconstructed Proto directory outside --source")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
manifest = reconstruct(args.source, args.output)
except (OSError, ValueError, UnicodeError) as exc:
raise SystemExit(f"extract_client_proto: {exc}")
stats = manifest["statistics"]
print(f"wrote {args.output.resolve()} ({stats['proto_files']} .proto files, {stats['messages']} messages)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Add the final costume-potential ledger to the pre-release collection save.
Dry-run by default. Applying requires stopped client/server, creates and
verifies a complete nine-file checkpoint, and atomically changes only
collection.json. No runtime compatibility for the unpublished old format is
kept in the Go server.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
from pathlib import Path
import save_checkpoint
from set_first_gacha_state import atomic_write_json
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
def migrate(value: dict) -> tuple[dict, bool]:
if value.get("version") != "2.34.13" or not isinstance(value.get("costumes", []), list):
raise ValueError("collection.json is not the expected 2.34.13 save")
if "costume_potential" in value:
ledger = value["costume_potential"]
if not isinstance(ledger, dict) or any(
not isinstance(key, str) or not isinstance(nodes, list) or
any(not isinstance(node, int) or isinstance(node, bool) or node <= 0 for node in nodes)
for key, nodes in ledger.items()
):
raise ValueError("existing costume_potential ledger is malformed")
return value, False
for costume in value.get("costumes", []):
if "potential_id" in costume or "potential_ids" in costume:
raise ValueError("unexpected legacy potential data requires explicit review")
result = dict(value)
result["costume_potential"] = {}
return result, True
def run(state: Path, apply: bool) -> Path | None:
target = state / "collection.json"
with target.open(encoding="utf-8") as stream:
original = json.load(stream)
migrated, changed = migrate(original)
print(json.dumps({"changed": changed, "costume_count": len(original.get("costumes", [])), "active_costume_count": len(migrated.get("costume_potential", {}))}, indent=2))
if not changed or not apply:
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
before = save_checkpoint.inspect_files(state)
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-costume-potential-state"
save_checkpoint.create(state, backup, "before adding final costume potential ledger")
atomic_write_json(target, migrated)
with target.open(encoding="utf-8") as stream:
written = json.load(stream)
repeated, changed_again = migrate(written)
after = save_checkpoint.inspect_files(state)
if changed_again or repeated != migrated or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "collection.json"):
raise OSError(f"costume potential migration failed verification; backup: {backup}")
save_checkpoint.verify(backup)
return backup
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
backup = run(args.state.resolve(), args.apply)
print(f"Migrated with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply with stopped processes when changed=true")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"migrate_costume_potential_state: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Add the final per-instance equipment upgrade-attempt counter.
Dry-run by default. Applying requires stopped client/server, creates a verified
nine-file checkpoint, and atomically changes only equipment.json. Existing
equipment has never been successfully upgraded, so every instance starts at 0.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
from pathlib import Path
import save_checkpoint
from set_first_gacha_state import atomic_write_json
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
def migrate(value: dict) -> tuple[dict, list[int]]:
if value.get("version") != "2.34.13" or not isinstance(value.get("equipment"), list):
raise ValueError("equipment.json is not the expected 2.34.13 save")
result = dict(value)
result["equipment"] = []
changed = []
for original in value["equipment"]:
if not isinstance(original, dict) or not isinstance(original.get("inven_index"), int):
raise ValueError("invalid equipment instance")
item = dict(original)
if "upgrade_attempts" not in item:
item["upgrade_attempts"] = 0
changed.append(item["inven_index"])
elif not isinstance(item["upgrade_attempts"], int) or isinstance(item["upgrade_attempts"], bool) or item["upgrade_attempts"] < 0:
raise ValueError(f"equipment {item['inven_index']} has invalid upgrade_attempts")
result["equipment"].append(item)
return result, changed
def run(state: Path, apply: bool) -> Path | None:
target = state / "equipment.json"
with target.open(encoding="utf-8") as stream:
original = json.load(stream)
migrated, changed = migrate(original)
print(json.dumps({"equipment_count": len(migrated["equipment"]), "changed_count": len(changed), "changed_indices": changed}, indent=2))
if not changed or not apply:
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
before = save_checkpoint.inspect_files(state)
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-equipment-upgrade-attempts"
save_checkpoint.create(state, backup, "before adding equipment upgrade attempt counters")
atomic_write_json(target, migrated)
with target.open(encoding="utf-8") as stream:
written = json.load(stream)
repeated, remaining = migrate(written)
after = save_checkpoint.inspect_files(state)
if remaining or repeated != migrated or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "equipment.json"):
raise OSError(f"equipment upgrade migration failed verification; backup: {backup}")
save_checkpoint.verify(backup)
return backup
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
backup = run(args.state.resolve(), args.apply)
print(f"Migrated with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply with stopped processes when changed_count>0")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"migrate_equipment_upgrade_attempts: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Repair pre-release equipment saves with missing three-slot rank arrays.
The 2.34.13 client indexes EquipBaseInfo.Rank[0..2] even before enhancement.
Dry-run by default. Applying requires stopped client/server processes, verifies
and backs up all nine state files, and atomically updates equipment.json only.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
from pathlib import Path
import save_checkpoint
from set_first_gacha_state import atomic_write_json
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
def repair(value: dict) -> tuple[dict, list[int]]:
if value.get("version") != "2.34.13":
raise ValueError("equipment.json must be a 2.34.13 save")
entries = value.get("equipment")
if not isinstance(entries, list):
raise ValueError("equipment must be an array")
result = dict(value)
result["equipment"] = []
updated = []
for item in entries:
if not isinstance(item, dict) or not isinstance(item.get("inven_index"), int):
raise ValueError("invalid equipment instance")
copy = dict(item)
if "rank" not in copy:
copy["rank"] = [0, 0, 0]
updated.append(copy["inven_index"])
elif not isinstance(copy["rank"], list) or len(copy["rank"]) != 3 or any(
not isinstance(rank, int) or isinstance(rank, bool) or rank < 0 or rank > 4
for rank in copy["rank"]
):
raise ValueError(f"equipment {copy['inven_index']} has an invalid rank array")
result["equipment"].append(copy)
return result, updated
def apply_state(state: Path, apply: bool) -> Path | None:
target = state / "equipment.json"
with target.open("r", encoding="utf-8") as stream:
original = json.load(stream)
repaired, updated = repair(original)
print(json.dumps({"equipment_count": len(repaired["equipment"]), "missing_rank_count": len(updated), "updated_indices": updated}, indent=2))
if not updated or not apply:
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
before = save_checkpoint.inspect_files(state)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = state / "checkpoints" / f"{stamp}-before-equipment-ranks"
save_checkpoint.create(state, backup, "before three-slot equipment rank repair")
atomic_write_json(target, repaired)
with target.open("r", encoding="utf-8") as stream:
written = json.load(stream)
_, remaining = repair(written)
after = save_checkpoint.inspect_files(state)
if remaining or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "equipment.json"):
raise OSError(f"equipment repair failed verification; backup: {backup}")
return backup
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
backup = apply_state(args.state.resolve(), args.apply)
if backup:
print(f"updated equipment.json; verified nine-file backup: {backup}")
elif not args.apply:
print("dry run only; pass --apply after stopping game and server")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"repair_equipment_ranks: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Undo the six rejected 2026-09-21 20:19–20:20 collection promotions.
The six /CharGrowth requests for collection character 920000054 (6510/1)
charged inventory and one gold payment before failing to persist a changed
character ID. This one-time repair only accepts the exact observed save and
six matching server errors, and removes the refund items those failed requests
created. Dry-run by default; applying requires stopped client/server, backs up
and verifies all nine account files, then writes only items.json/wallet.json.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
from pathlib import Path
import save_checkpoint
from set_first_gacha_state import atomic_write_json
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
DEFAULT_LOG = Path(__file__).resolve().parents[2] / "logs" / "server-20260921-201837.err.log"
ERROR = "persist promoted collection character: player: collection character 920000054 not found"
IDENTITY = "char-promote:920000054:6510"
CONSUMED = {
900000038: (11, 93, 1),
900000041: (12, 86, 2),
900000042: (9, 94738, 753),
900000045: (14, 99973, 4),
900000046: (13, 99980, 3),
}
REFUNDS = {index: (8 if index % 2 else 7, 1 if index % 2 else 3) for index in range(900000049, 900000061)}
def repair(items: dict, wallet: dict, collection: dict, log: str) -> tuple[dict, dict]:
errors = [line for line in log.splitlines() if ERROR in line]
if len(errors) != 6 or any("path=/CharGrowth" not in line for line in errors):
raise ValueError(f"expected exactly six rejected promotion requests, got {len(errors)}")
if any(value.get("version") != "2.34.13" for value in (items, wallet, collection)):
raise ValueError("unexpected save version")
owner = [char for char in collection.get("characters", []) if char.get("inven_index") == 920000054]
if len(owner) != 1 or owner[0].get("id") != 6510 or owner[0].get("level") != 1:
raise ValueError("collection character no longer matches the rejected request")
if wallet.get("gold") != 56050 or wallet.get("spent", {}).get(IDENTITY) is not True:
raise ValueError("wallet differs from the observed failed payment")
inventory = {item.get("inven_index"): item for item in items.get("items", [])}
for index, (item_id, remaining, _) in CONSUMED.items():
item = inventory.get(index)
if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != remaining:
raise ValueError(f"item stack {index} differs from the observed failed requests")
for index, (item_id, count) in REFUNDS.items():
item = inventory.get(index)
if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != count:
raise ValueError(f"failed-request refund {index} was changed or used")
for indices in items.get("grant_items", {}).values():
if any(index in REFUNDS for index in indices):
raise ValueError("a failed-request refund has a separate grant reference")
corrected_items = dict(items)
corrected_items["items"] = []
for original in items["items"]:
index = original["inven_index"]
if index in REFUNDS:
continue
item = dict(original)
if index in CONSUMED:
item["count"] += 6 * CONSUMED[index][2]
corrected_items["items"].append(item)
corrected_wallet = dict(wallet)
corrected_wallet["gold"] = wallet["gold"] + 10000
corrected_wallet["spent"] = dict(wallet["spent"])
del corrected_wallet["spent"][IDENTITY]
return corrected_items, corrected_wallet
def run(state: Path, log_path: Path, apply: bool) -> Path | None:
before = save_checkpoint.inspect_files(state)
values = {}
for name in ("items", "wallet", "collection"):
with (state / f"{name}.json").open(encoding="utf-8") as stream:
values[name] = json.load(stream)
repaired_items, repaired_wallet = repair(values["items"], values["wallet"], values["collection"], log_path.read_text(encoding="utf-8"))
print(json.dumps({
"failed_requests": 6,
"character": "920000054 / 6510 / level 1 (unchanged)",
"restored_gold": 10000,
"restored_item_counts": {str(index): 6 * value[2] for index, value in CONSUMED.items()},
"removed_unearned_refund_indices": sorted(REFUNDS),
}, indent=2))
if not apply:
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-failed-collection-growth-repair"
save_checkpoint.create(state, backup, "before undoing six rejected collection CharGrowth requests")
atomic_write_json(state / "items.json", repaired_items)
atomic_write_json(state / "wallet.json", repaired_wallet)
after = save_checkpoint.inspect_files(state)
if any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name not in {"items.json", "wallet.json"}):
raise OSError(f"unrelated account state changed; backup: {backup}")
for name, expected in (("items.json", repaired_items), ("wallet.json", repaired_wallet)):
with (state / name).open(encoding="utf-8") as stream:
if json.load(stream) != expected:
raise OSError(f"failed verification of {name}; backup: {backup}")
save_checkpoint.verify(backup)
return backup
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
parser.add_argument("--log", type=Path, default=DEFAULT_LOG)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
backup = run(args.state.resolve(), args.log.resolve(), args.apply)
print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run only; pass --apply after stopping client/server")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"repair_failed_collection_growth: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Remove internal ResourceTable sentinels accidentally granted by dev mail.
Dry-run by default. Applying requires stopped client/server, creates a verified
nine-file checkpoint, and atomically changes only items.json. Historical mail
grant ledgers remain so the invalid attachment cannot be replayed.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
from pathlib import Path
import save_checkpoint
from set_first_gacha_state import atomic_write_json
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
INVALID_RESOURCE_IDS = {90045, 90046}
def repair(value: dict) -> tuple[dict, list[int]]:
if value.get("version") != "2.34.13" or not isinstance(value.get("items"), list):
raise ValueError("items.json is not the expected 2.34.13 save")
grant_items = value.get("grant_items")
granted = value.get("granted")
if not isinstance(grant_items, dict) or not isinstance(granted, dict):
raise ValueError("items.json grant ledgers are missing")
bad_indices: list[int] = []
for item in value["items"]:
if not isinstance(item, dict):
raise ValueError("invalid item instance")
if item.get("type") == 8 and item.get("id") in INVALID_RESOURCE_IDS:
index = item.get("inven_index")
if not isinstance(index, int) or index <= 0:
raise ValueError("invalid internal-resource inventory index")
owners = [
identity for identity, indices in grant_items.items()
if isinstance(indices, list) and index in indices
]
if len(owners) != 1 or not owners[0].startswith("mail:") or not granted.get(owners[0]):
raise ValueError(f"internal resource {index} is not an acknowledged mail grant")
bad_indices.append(index)
result = dict(value)
result["items"] = [item for item in value["items"] if item.get("inven_index") not in set(bad_indices)]
return result, bad_indices
def run(state: Path, apply: bool) -> Path | None:
target = state / "items.json"
with target.open(encoding="utf-8") as stream:
original = json.load(stream)
repaired, removed = repair(original)
print(json.dumps({"removed_count": len(removed), "removed_indices": removed}, indent=2))
if not removed or not apply:
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
before = save_checkpoint.inspect_files(state)
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-invalid-dev-mail-resource-repair"
save_checkpoint.create(state, backup, "before removing invalid developer-mail resource sentinels")
atomic_write_json(target, repaired)
with target.open(encoding="utf-8") as stream:
written = json.load(stream)
repeated, remaining = repair(written)
after = save_checkpoint.inspect_files(state)
if remaining or repeated != repaired or any(
before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "items.json"
):
raise OSError(f"invalid developer-mail resource repair failed verification; backup: {backup}")
save_checkpoint.verify(backup)
return backup
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
backup = run(args.state.resolve(), args.apply)
print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply after stopping the client/server")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"repair_invalid_dev_mail_resources: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -84,7 +84,7 @@ def running_game_processes() -> list[str]:
names = []
for line in result.stdout.splitlines():
lowered = line.lower()
if "browndust2" in lowered or "bd2server.exe" in lowered:
if "browndust2" in lowered or "brown dust ii" in lowered or "bd2server.exe" in lowered:
names.append(line)
return names
+9 -1
View File
@@ -66,10 +66,18 @@ def running_processes() -> list[str]:
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(
"cannot verify that the client/server are stopped; "
f"tasklist failed with exit code {result.returncode}: {detail}"
)
return [
line
for line in result.stdout.splitlines()
if "browndust2" in line.lower() or "bd2server.exe" in line.lower()
if "browndust2" in line.lower()
or "brown dust ii" in line.lower()
or "bd2server.exe" in line.lower()
]
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Set the explicit first-gacha completion marker in a development save.
This is a pre-release schema edit, not a compatibility migration. By default
it prints the proposed collection.json change. Applying requires stopped game
and server processes, creates a verified nine-file checkpoint, and atomically
replaces only collection.json.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
import os
from pathlib import Path
import sys
import save_checkpoint
IDENTITY = "account:first-gacha-completed"
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state"
def updated_collection(value: dict, completed: bool) -> tuple[dict, bool]:
if value.get("version") != "2.34.13":
raise ValueError("collection.json is not a 2.34.13 save")
grants = value.get("grants")
if not isinstance(grants, dict):
raise ValueError("collection.json grants must be an object")
result = dict(value)
result["grants"] = dict(grants)
before = IDENTITY in grants
if completed:
existing = grants.get(IDENTITY)
if before and existing != {}:
raise ValueError("first-gacha marker exists with unexpected payload")
result["grants"][IDENTITY] = {}
else:
result["grants"].pop(IDENTITY, None)
return result, before != completed
def atomic_write_json(path: Path, value: dict) -> None:
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
json.dump(value, stream, ensure_ascii=False, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def apply_state(state: Path, completed: bool, apply: bool) -> Path | None:
collection_path = state / "collection.json"
with collection_path.open("r", encoding="utf-8") as stream:
current = json.load(stream)
updated, changed = updated_collection(current, completed)
print(json.dumps({
"identity": IDENTITY,
"before": IDENTITY in current["grants"],
"after": completed,
"changed": changed,
}, ensure_ascii=False, indent=2))
if not changed:
return None
if not apply:
print("dry run only; stop client/server and pass --apply", file=sys.stderr)
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
save_checkpoint.inspect_files(state)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = state / "checkpoints" / f"{stamp}-before-first-gacha-state"
save_checkpoint.create(state, backup, "before explicit first-gacha state edit")
atomic_write_json(collection_path, updated)
with collection_path.open("r", encoding="utf-8") as stream:
written = json.load(stream)
if (IDENTITY in written.get("grants", {})) != completed:
raise OSError(f"written first-gacha state failed verification; backup: {backup}")
save_checkpoint.inspect_files(state)
return backup
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
selection = parser.add_mutually_exclusive_group(required=True)
selection.add_argument("--completed", action="store_true")
selection.add_argument("--not-completed", action="store_true")
parser.add_argument("--apply", action="store_true")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
backup = apply_state(args.state.resolve(), args.completed, args.apply)
if backup is not None:
print(f"updated {args.state.resolve() / 'collection.json'}; backup: {backup}")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"set_first_gacha_state: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+313
View File
@@ -7,8 +7,10 @@ import sys
import tempfile
import unittest
import zipfile
from unittest import mock
from Crypto.Cipher import AES
from google.protobuf import descriptor_pb2
TOOLS = Path(__file__).resolve().parents[1]
@@ -17,6 +19,15 @@ sys.path.insert(0, str(TOOLS))
import gamedata_db
import import_seed
import save_checkpoint
import deobfuscate_client_source
import extract_client_proto
import set_first_gacha_state
import repair_equipment_ranks
import repair_failed_collection_growth
import migrate_costume_potential_state
import migrate_equipment_upgrade_attempts
import dev_mail_grant
import repair_invalid_dev_mail_resources
class Arguments:
@@ -100,6 +111,93 @@ class ImportToolTests(unittest.TestCase):
class CheckpointToolTests(unittest.TestCase):
def test_invalid_dev_mail_resource_repair_preserves_grant_ledger(self):
identity = "mail:13043056739:items"
original = {
"version": "2.34.13",
"items": [
{"inven_index": 10, "id": 90045, "type": 8, "count": 1},
{"inven_index": 11, "id": 127, "type": 8, "count": 99},
],
"granted": {identity: True},
"grant_items": {identity: [10]},
}
repaired, removed = repair_invalid_dev_mail_resources.repair(original)
self.assertEqual(removed, [10])
self.assertEqual([item["inven_index"] for item in repaired["items"]], [11])
self.assertEqual(repaired["granted"], original["granted"])
self.assertEqual(repaired["grant_items"], original["grant_items"])
repeated, removed = repair_invalid_dev_mail_resources.repair(repaired)
self.assertEqual(removed, [])
self.assertEqual(repeated, repaired)
def test_process_check_fails_closed_when_tasklist_is_unavailable(self):
failed = mock.Mock(returncode=1, stdout="", stderr="ERROR: Access denied")
with mock.patch.object(save_checkpoint.os, "name", "nt"), mock.patch.object(save_checkpoint.subprocess, "run", return_value=failed):
with self.assertRaisesRegex(RuntimeError, "cannot verify"):
save_checkpoint.running_processes()
def test_equipment_upgrade_attempt_migration_is_explicit_and_idempotent(self):
original = {"version": "2.34.13", "equipment": [{"inven_index": 1, "id": 10010}]}
migrated, changed = migrate_equipment_upgrade_attempts.migrate(original)
self.assertEqual(changed, [1])
self.assertEqual(migrated["equipment"][0]["upgrade_attempts"], 0)
self.assertNotIn("upgrade_attempts", original["equipment"][0])
repeated, changed = migrate_equipment_upgrade_attempts.migrate(migrated)
self.assertEqual(changed, [])
self.assertEqual(repeated, migrated)
def test_costume_potential_state_migration_is_final_and_idempotent(self):
original = {"version": "2.34.13", "costumes": [{"inven_index": 1, "id": 1001}]}
migrated, changed = migrate_costume_potential_state.migrate(original)
self.assertTrue(changed)
self.assertEqual(migrated["costume_potential"], {})
self.assertNotIn("costume_potential", original)
repeated, changed = migrate_costume_potential_state.migrate(migrated)
self.assertFalse(changed)
self.assertEqual(repeated, migrated)
with self.assertRaises(ValueError):
migrate_costume_potential_state.migrate({"version": "2.34.13", "costumes": [{"potential_id": 1}]})
def test_failed_collection_growth_repair_requires_exact_six_unchanged_requests(self):
module = repair_failed_collection_growth
items = {"version": "2.34.13", "grant_items": {}, "next_index": 900000061, "items": [
{"inven_index": index, "id": value[0], "type": 8, "count": value[1]}
for index, value in module.CONSUMED.items()
] + [
{"inven_index": index, "id": value[0], "type": 8, "count": value[1]}
for index, value in module.REFUNDS.items()
]}
wallet = {"version": "2.34.13", "gold": 56050, "spent": {module.IDENTITY: True}}
collection = {"version": "2.34.13", "characters": [
{"inven_index": 920000054, "id": 6510, "level": 1},
]}
line = f"WARN session packet rejected path=/CharGrowth error={module.ERROR}\n"
corrected_items, corrected_wallet = module.repair(items, wallet, collection, line * 6)
self.assertEqual(corrected_wallet["gold"], 66050)
self.assertNotIn(module.IDENTITY, corrected_wallet["spent"])
self.assertEqual(len(corrected_items["items"]), len(module.CONSUMED))
self.assertEqual(next(item["count"] for item in corrected_items["items"] if item["inven_index"] == 900000042), 99256)
self.assertEqual(items["items"][2]["count"], 94738)
with self.assertRaises(ValueError):
module.repair(items, wallet, collection, line * 5)
def test_equipment_rank_repair_only_fills_missing_arrays(self):
original = {"version": "2.34.13", "equipment": [
{"inven_index": 1, "id": 10010},
{"inven_index": 2, "id": 943619, "rank": [0, 2, 0]},
]}
repaired, indices = repair_equipment_ranks.repair(original)
self.assertEqual(indices, [1])
self.assertEqual(repaired["equipment"][0]["rank"], [0, 0, 0])
self.assertEqual(repaired["equipment"][1]["rank"], [0, 2, 0])
self.assertNotIn("rank", original["equipment"][0])
repeated, indices = repair_equipment_ranks.repair(repaired)
self.assertEqual(indices, [])
self.assertEqual(repeated, repaired)
with self.assertRaises(ValueError):
repair_equipment_ranks.repair({"version": "2.34.13", "equipment": [{"inven_index": 3, "rank": [1, 2]}]})
def test_checkpoint_hash_manifest(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -111,6 +209,221 @@ class CheckpointToolTests(unittest.TestCase):
created = save_checkpoint.create(state, target, "test")
self.assertEqual(save_checkpoint.verify(target), created)
def test_first_gacha_state_edit_is_explicit_and_idempotent(self):
original = {"version": "2.34.13", "grants": {"draw": {}}}
completed, changed = set_first_gacha_state.updated_collection(original, True)
self.assertTrue(changed)
self.assertEqual(completed["grants"][set_first_gacha_state.IDENTITY], {})
repeated, changed = set_first_gacha_state.updated_collection(completed, True)
self.assertFalse(changed)
pending, changed = set_first_gacha_state.updated_collection(repeated, False)
self.assertTrue(changed)
self.assertNotIn(set_first_gacha_state.IDENTITY, pending["grants"])
class DevelopmentMailGrantToolTests(unittest.TestCase):
def test_deterministic_random_box_is_replaced_by_direct_material(self):
items = [
{"id": 400131, "element_type": 9, "name": "装备制作所需材料", "category": "随机箱"},
{"id": 127, "element_type": 8, "name": "<未找到本地化文本 #32127>", "category": "资源"},
{"id": 999, "element_type": 9, "name": "真正随机箱", "category": "随机箱"},
]
mapped = dev_mail_grant.map_fixed_boxes_to_direct_items(
items,
{400131: (8, 127, 1)},
{400131: {"女神之泪": 1}},
)
self.assertNotIn((9, 400131), {(item["element_type"], item["id"]) for item in mapped})
self.assertNotIn((9, 999), {(item["element_type"], item["id"]) for item in mapped})
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("400131", material["details"])
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": "金币遗失物品",
"source_table": "ResourceTable", "resource_type": 2,
}))
def test_gold_currency_mail_uses_type_four_id_zero_and_requested_count(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "mail.json"
output = root / "generated.json"
source.write_text(json.dumps({
"version": "2.34.13", "mails": [], "mail_count": 1, "max_mail_id": 0,
}), encoding="utf-8")
gold = {"id": 0, "element_type": 4, "name": "金币"}
store = dev_mail_grant.MailGrantStore(source, output, [gold], 365)
result = store.grant({"item_id": 0, "element_type": 4, "count": 123456789})
self.assertEqual(result["mail"]["reward_types"], [4])
self.assertEqual(result["mail"]["reward_ids"], [0])
self.assertEqual(result["mail"]["reward_counts"], [123456789])
def test_packed_varints_accepts_repeated_and_packed_fields(self):
self.assertEqual(dev_mail_grant.packed_varints({4: [3, b"\x80\x01\x02"]}, 4), [3, 128, 2])
with self.assertRaises(ValueError):
dev_mail_grant.packed_varints({4: [b"\x80"]}, 4)
def test_item_picker_is_collapsible_and_scrolls_its_list(self):
self.assertIn('<details id=item-picker>', dev_mail_grant.PAGE)
self.assertIn('<div class=item-list>', dev_mail_grant.PAGE)
self.assertIn('max-height:min(40vh,28rem)', dev_mail_grant.PAGE)
self.assertIn('overflow:auto', dev_mail_grant.PAGE)
self.assertIn("$('item-picker').open=false", dev_mail_grant.PAGE)
def test_picker_search_includes_random_box_aliases(self):
self.assertIn("x.category+' '+(x.details||'')", dev_mail_grant.PAGE)
def test_grant_writes_complete_seed_without_state_mutation(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source-mail.json"
output = root / "generated-mail.json"
source.write_text(json.dumps({
"version": "2.34.13",
"mails": [{
"mail_id": 100, "mail_type": 2, "title": "base", "body": "base",
"expires_at": 200, "reward_types": [8], "reward_ids": [7],
"reward_counts": [1], "sent_at": 100,
}],
"mail_count": 2, "max_mail_id": 100,
}), encoding="utf-8")
original_source = source.read_text(encoding="utf-8")
store = dev_mail_grant.MailGrantStore(source, output, [{
"id": 9, "element_type": 8, "name": "slime",
}], 365)
self.assertTrue(output.is_file())
self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["mail_count"], 2)
result = store.grant({"item_id": 9, "element_type": 8, "count": 123, "title": "test", "body": "body"})
written = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(source.read_text(encoding="utf-8"), original_source)
self.assertEqual(written["mail_count"], 3)
self.assertEqual(written["max_mail_id"], 101)
self.assertEqual(written["mails"][-1]["reward_types"], [8])
self.assertEqual(written["mails"][-1]["reward_ids"], [9])
self.assertEqual(written["mails"][-1]["reward_counts"], [123])
self.assertFalse(result["restart_required"])
with self.assertRaises(ValueError):
store.grant({"item_id": 999, "element_type": 8, "count": 1})
with self.assertRaises(ValueError):
store.grant({"item_id": 9, "element_type": 8, "count": dev_mail_grant.MAX_INT32 + 1})
class ClientSourceToolTests(unittest.TestCase):
def test_deobfuscates_code_not_comments_or_literals_and_records_collisions(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "Assembly-CSharp"
source.mkdir()
mapping = root / "translation.obfuscate"
mapping.write_text(
"#ReverseOrder\n"
"α⇨Net.Player/User-Info\n"
"β⇨Other.User Info\n"
"γ⇨class\n"
"δ⇨Meaning.D\n",
encoding="utf-8",
)
(source / "α.cs").write_text(
"// α β γ δ\n"
"class α { string v = \"α β γ δ\"; char x = 'α'; α f; β g; γ h; δ i; }\n",
encoding="utf-8",
)
(source / "asset.bin").write_bytes(b"\x00a")
output = root / "mirror"
manifest = deobfuscate_client_source.deobfuscate(source, mapping, output)
text = (output / "User_Info.cs").read_text(encoding="utf-8")
self.assertIn("// α β γ δ", text)
self.assertIn('"α β γ δ"', text)
self.assertIn("char x = 'α'", text)
self.assertIn("class User_Info", text)
self.assertIn("User_Info__from_u03B2 g", text)
self.assertIn("_class h", text)
self.assertIn("D i", text)
self.assertEqual((output / "asset.bin").read_bytes(), b"\x00a")
self.assertEqual(manifest["statistics"]["identifier_replacements"], 5)
self.assertTrue((output / deobfuscate_client_source.MANIFEST).is_file())
def test_deobfuscator_skips_ambiguous_scoped_symbol(self):
entries = [
{"line": 1, "source": "α", "meaning": "One.Value"},
{"line": 2, "source": "α", "meaning": "Two.Value"},
]
replacements, report, warnings = deobfuscate_client_source.build_replacements(entries)
self.assertNotIn("α", replacements)
self.assertEqual(report, [])
self.assertTrue(any("ambiguous" in warning for warning in warnings))
def test_deobfuscator_refuses_output_inside_source_or_unmanaged_output(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
mapping = root / "map"
mapping.write_text("a⇨Name\n", encoding="utf-8")
with self.assertRaises(ValueError):
deobfuscate_client_source.deobfuscate(source, mapping, source / "out")
output = root / "output"
output.mkdir()
(output / "someone.txt").write_text("keep", encoding="utf-8")
with self.assertRaises(FileExistsError):
deobfuscate_client_source.deobfuscate(source, mapping, output)
def test_reconstructs_proto_and_lossless_descriptor_set(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "Assembly-CSharp"
net = source / "Proto" / "Net"
net.mkdir(parents=True)
descriptor = descriptor_pb2.FileDescriptorProto(
name="Request/Login.proto", package="proto.net", syntax="proto3"
)
message = descriptor.message_type.add(name="LoginRequest")
message.field.add(
name="seq", number=1,
label=descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL,
type=descriptor_pb2.FieldDescriptorProto.TYPE_INT32,
)
encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii")
(net / "LoginRequestReflection.cs").write_text(
"private static FileDescriptor descriptor = "
"FileDescriptor.FromGeneratedCode(Convert.FromBase64String("
f"string.Concat(new string[] {{ \"{encoded}\" }})), new FileDescriptor[0], info);",
encoding="utf-8",
)
output = root / "proto-view"
manifest = extract_client_proto.reconstruct(source, output)
proto = (output / "Request" / "Login.proto").read_text(encoding="utf-8")
self.assertIn('syntax = "proto3";', proto)
self.assertIn("package proto.net;", proto)
self.assertIn("message LoginRequest", proto)
self.assertIn("int32 seq = 1;", proto)
saved = descriptor_pb2.FileDescriptorSet()
saved.ParseFromString((output / extract_client_proto.DESCRIPTOR_SET).read_bytes())
self.assertEqual(saved.file[0], descriptor)
self.assertEqual(manifest["statistics"]["proto_files"], 1)
def test_proto_extractor_rejects_unmanaged_or_nested_outputs(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source"
(source / "Proto" / "Net").mkdir(parents=True)
with self.assertRaises(ValueError):
extract_client_proto.reconstruct(source, source / "view")
descriptor = descriptor_pb2.FileDescriptorProto(name="X.proto", syntax="proto3")
encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii")
(source / "Proto" / "Net" / "XReflection.cs").write_text(
"Convert.FromBase64String(string.Concat(new string[] {"
f"\"{encoded}\"" + "})), new FileDescriptor[0], info);",
encoding="utf-8",
)
output = root / "view"
output.mkdir()
with self.assertRaises(FileExistsError):
extract_client_proto.reconstruct(source, output)
if __name__ == "__main__":
unittest.main()