Files
GaCy-GO/tools/export_ffgo_embedded_resources.py
T

113 lines
5.4 KiB
Python
Raw Normal View History

2026-10-28 18:54:43 +07:00
"""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()