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:
@@ -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` 键:
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user