114 lines
5.4 KiB
Python
114 lines
5.4 KiB
Python
"""Extract reference Excel/custom data offline; never execute the reference binary.
|
|
|
|
Requires protobuf plus the sibling dump_embedded_proto.py. Output is evidence,
|
|
not an automatically trusted replacement for current Client configuration.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
from google.protobuf import descriptor_pool, json_format, message_factory
|
|
from dump_embedded_proto import extract
|
|
|
|
|
|
def sha(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def save_json(path, value):
|
|
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--binary", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
binary = args.binary.read_bytes()
|
|
args.output.mkdir(parents=True, exist_ok=True)
|
|
pool = descriptor_pool.DescriptorPool()
|
|
descriptors = []
|
|
for name in ("gridfight_excel", "rogue_tourn_excel", "resources"):
|
|
start, end, desc = extract(binary, "proto/" + name + ".proto")
|
|
pool.Add(desc)
|
|
descriptors.append({"name": desc.name, "offset": start,
|
|
"length": end - start, "sha256": sha(binary[start:end])})
|
|
excel_type = message_factory.GetMessageClass(pool.FindMessageTypeByName("excel.ExcelData"))
|
|
manifest = {"format_version": 1, "binary_sha256": sha(binary),
|
|
"binary_size": len(binary), "descriptors": descriptors,
|
|
"resources": [], "limitations": [
|
|
"Reference binary data; no runtime execution or Client verification.",
|
|
"Custom config files are reference-authored rules, not original Client JSON.",
|
|
"int64 protobuf fields remain JSON strings to retain exact values."]}
|
|
found = set()
|
|
# Exact gzip headers, finite carrier size and 64 MiB output cap per candidate.
|
|
for match in re.finditer(b"\x1f\x8b\x08\x00", binary):
|
|
offset = match.start()
|
|
try:
|
|
decoder = zlib.decompressobj(31)
|
|
raw = decoder.decompress(binary[offset:], 64 * 1024 * 1024)
|
|
if not decoder.eof:
|
|
continue
|
|
compressed_length = len(binary) - offset - len(decoder.unused_data)
|
|
if raw.startswith(b"{"):
|
|
value = json.loads(raw)
|
|
if not isinstance(value, dict) or "config_files" not in value:
|
|
continue
|
|
stem = "gridfight-custom-data"
|
|
else:
|
|
message = excel_type.FromString(raw)
|
|
if not message.HasField("grid_fight"):
|
|
continue
|
|
value = json_format.MessageToDict(message, preserving_proto_field_name=True)
|
|
stem = "excel-data"
|
|
if stem in found:
|
|
raise RuntimeError("Ambiguous embedded resource: " + stem)
|
|
found.add(stem)
|
|
save_json(args.output / (stem + ".json"), value)
|
|
if stem == "excel-data":
|
|
save_json(args.output / "gridfight-excel-data.json", value["grid_fight"])
|
|
manifest["resources"].append({"output": stem + ".json", "offset": offset,
|
|
"compressed_length": compressed_length, "compressed_sha256": sha(binary[offset:offset+compressed_length]),
|
|
"uncompressed_length": len(raw), "uncompressed_sha256": sha(raw)})
|
|
except (zlib.error, ValueError, UnicodeError):
|
|
continue
|
|
except Exception as error:
|
|
# A random gzip-like sequence need not be a protobuf resource.
|
|
if type(error).__name__ != "DecodeError":
|
|
raise
|
|
if found != {"excel-data", "gridfight-custom-data"}:
|
|
raise RuntimeError("Expected unique Excel and CW custom resources; got " + repr(found))
|
|
# This separate JSON supplement is not part of ExcelData. Locate its named
|
|
# root rather than baking in one build's offset or any game identifiers.
|
|
supplements = []
|
|
for match in re.finditer(rb'\{\s*"challenge_peak_extra_data"\s*:', binary):
|
|
candidate = binary[match.start():match.start()+1024*1024]
|
|
try:
|
|
# Decode only through the JSON terminator; following PE bytes are
|
|
# unrelated and may not be valid UTF-8.
|
|
text = candidate.decode("utf-8", errors="surrogateescape")
|
|
value, end = json.JSONDecoder().raw_decode(text)
|
|
raw = text[:end].encode("utf-8", errors="surrogateescape")
|
|
if isinstance(value.get("challenge_peak_extra_data"), list):
|
|
supplements.append((match.start(), raw, value))
|
|
except (ValueError, UnicodeError):
|
|
continue
|
|
if len(supplements) != 1:
|
|
raise RuntimeError("Expected unique named Peak scene supplement")
|
|
offset, raw, value = supplements[0]
|
|
save_json(args.output / "challenge_peak_extra_data.json", {
|
|
"binary_sha256": sha(binary), "source_file_offset": hex(offset),
|
|
"decoded_byte_length": len(raw), "data": value})
|
|
manifest["resources"].append({"output": "challenge_peak_extra_data.json",
|
|
"offset": offset, "uncompressed_length": len(raw),
|
|
"uncompressed_sha256": sha(raw), "encoding": "utf-8-json"})
|
|
save_json(args.output / "resource-manifest.json", manifest)
|
|
print(json.dumps({"binary_sha256": manifest["binary_sha256"], "resources": manifest["resources"]}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|