Files
bd2/tools/python/tests/test_offline_tools.py
T
Flechazo 113e941bef feat: expand gameplay and state persistence
- add character imprint, awakening, and equipment refinement

- add equipment ticket gacha and persistent progression fields

- add request-scoped account transactions with crash recovery

- persist age confirmation and isolate client plugin patches

- separate one-off save repair tools from shipped tooling
2026-09-23 00:55:36 +08:00

312 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import base64
import json
from pathlib import Path
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]
sys.path.insert(0, str(TOOLS))
import gamedata_db
import import_seed
import deobfuscate_client_source
import extract_client_proto
import dev_mail_grant
class Arguments:
pass
class GameDataToolTests(unittest.TestCase):
def test_extracts_and_decrypts_logical_database(self):
plain = bytearray(gamedata_db.PAGE_SIZE)
plain[: len(gamedata_db.HEADER)] = gamedata_db.HEADER
key = gamedata_db.derive_key()
encrypted = AES.new(key, AES.MODE_CBC, gamedata_db.HEADER).encrypt(bytes(plain))
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
release = root / "123" / "release"
release.mkdir(parents=True)
with zipfile.ZipFile(release / gamedata_db.ARCHIVE_NAME, "w") as archive:
archive.writestr(gamedata_db.database_entry("pack21"), encrypted)
self.assertEqual(gamedata_db.read_database(root, "123", "pack21"), bytes(plain))
def test_wire_field_selection(self):
proto = import_seed.encode_field(1, 0, 42) + import_seed.encode_field(3, 2, b"abc")
decoded = gamedata_db.wire_fields(proto, {1, 3})
self.assertEqual(decoded[1][0]["varint"], 42)
self.assertEqual(decoded[3][0]["utf8"], "abc")
class ImportToolTests(unittest.TestCase):
def test_login_import_removes_captured_key(self):
user = import_seed.encode_field(1, 0, 7)
user += import_seed.encode_field(3, 2, b"captured-secret")
proto = import_seed.encode_field(1, 2, user) + import_seed.encode_field(4, 0, 9)
with tempfile.TemporaryDirectory() as temporary:
source = Path(temporary) / "login.pb"
source.write_bytes(proto)
args = Arguments()
args.input, args.packet_code = source, 11
result = import_seed.import_login(args)
imported_user = base64.b64decode(result["user_info_base64"])
self.assertFalse(any(field.number == 3 for field in import_seed.fields(imported_user)))
self.assertEqual(base64.b64decode(result["response_fields_base64"]), import_seed.encode_field(4, 0, 9))
def test_starter_and_mail_import(self):
item = b"".join(
import_seed.encode_field(number, 0, value)
for number, value in ((1, 10), (2, 8), (3, 8), (4, 3))
)
costume = import_seed.encode_field(1, 0, 20) + import_seed.encode_field(2, 0, 3501)
character = import_seed.encode_field(1, 0, 30) + import_seed.encode_field(2, 0, 350)
mail = b"".join(
import_seed.encode_field(number, 0, value)
for number, value in ((1, 1), (2, 2), (7, 100), (13, 50))
)
mail += import_seed.encode_field(8, 2, import_seed.encode_varint(8))
mail += import_seed.encode_field(9, 2, import_seed.encode_varint(7))
mail += import_seed.encode_field(10, 2, import_seed.encode_varint(3))
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name, proto in {
"items.pb": import_seed.encode_field(1, 2, item),
"costumes.pb": import_seed.encode_field(1, 2, costume),
"characters.pb": import_seed.encode_field(1, 2, character),
"mail.pb": import_seed.encode_field(1, 2, mail)
+ import_seed.encode_field(2, 0, 2)
+ import_seed.encode_field(3, 0, 1),
}.items():
(root / name).write_bytes(proto)
args = Arguments()
args.items, args.costumes, args.characters = (
root / "items.pb",
root / "costumes.pb",
root / "characters.pb",
)
starter = import_seed.import_starter(args)
args.input = root / "mail.pb"
mailbox = import_seed.import_mail(args)
self.assertEqual(starter["items"][0]["count"], 3)
self.assertEqual(starter["costumes"][0]["id"], 3501)
self.assertEqual(starter["characters"][0]["id"], 350)
self.assertEqual(mailbox["mails"][0]["reward_counts"], [3])
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()