Files
GaCy-GO/tools/export_scene_metadata.py
T

75 lines
4.0 KiB
Python

"""Export complete RuntimeFloor group captures from a Client Player.log.
The capture must include FLOOR/GROUP/END records from a read-only Client
GameCoreConfigLoader probe. Inputs and Client revision are explicit arguments.
"""
import argparse
import hashlib
import json
import pathlib
import re
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("log", type=pathlib.Path)
parser.add_argument("--assembly", type=pathlib.Path, required=True)
parser.add_argument("--client-version", required=True)
parser.add_argument("--output", type=pathlib.Path, required=True)
parser.add_argument("--merge", action="store_true", help="Preserve compatible previously captured floors in output")
args = parser.parse_args()
floors, completed = {}, set()
placements = {}
placement_completed = set()
for line in args.log.read_text(encoding="utf-8", errors="replace").splitlines():
if "[GAC_SCENE_PLACEMENT] " in line:
row = line.split("[GAC_SCENE_PLACEMENT] ", 1)[1]
end_placement = re.fullmatch(r"END (\d+)", row)
if end_placement:
placement_completed.add(end_placement[1])
position = re.fullmatch(r"(ANCHOR|MONSTER) (\d+) (\d+)(?: (\d+))? pos=([^,]+),([^,]+),([^ ]+) rot=(.+)", row)
if position:
kind, floor, first_id, inst_id, x, y, z, rot = position.groups()
# Unity metres/degrees -> protocol position/rotation units.
value = {"pos": {"x": round(float(x)*1000), "y": round(float(y)*1000), "z": round(float(z)*1000)}, "rot": {"x": 0, "y": round(float(rot)*1000), "z": 0}}
target = placements.setdefault(floor, {"monsters": {}})
if kind == "ANCHOR":
value["ID"] = int(first_id)
target["start_anchor"] = value
else:
target["monsters"].setdefault(first_id, {})[inst_id] = value
continue
if "[GAC_SCENE_META]" not in line:
continue
row = line.split("[GAC_SCENE_META] ", 1)[1]
start = re.fullmatch(r"FLOOR (\d+) (\d+) startGroup=(\d+) anchor=(\d+) count=(\d+)", row)
group = re.fullmatch(r"GROUP (\d+) (\d+) side=(\w+): \d+ hoyo=(true|false) deleted=(true|false)", row)
end = re.fullmatch(r"END (\d+)", row)
if start:
plane, floor, start_group, anchor, count = start.groups()
floors[floor] = {"plane_id": int(plane), "group_count": int(count), "groups": {}}
completed.discard(floor)
elif group:
floor, group_id, side, hoyo, deleted = group.groups()
if floor in floors:
floors[floor]["groups"][group_id] = {"load_side": side, "is_hoyo_group": hoyo == "true", "is_deleted": deleted == "true"}
elif end:
completed.add(end[1])
if not floors or any(key not in completed or len(value["groups"]) != value["group_count"] for key, value in floors.items()):
raise SystemExit("Incomplete scene metadata capture; no output written")
for key, value in placements.items():
if key in floors and key in placement_completed:
floors[key].update(value)
result = {"client_version": args.client_version, "source": {
"kind": "Client GameCoreConfigLoader.LoadRtLevelFloorInfo / GroupInstanceCommonMap",
"assembly_sha256": hashlib.file_digest(args.assembly.open("rb"), "sha256").hexdigest(),
"capture_sha256": hashlib.sha256(args.log.read_bytes()).hexdigest(),
}, "floors": floors}
if args.merge and args.output.exists():
previous = json.loads(args.output.read_text(encoding="utf-8"))
if previous["client_version"] != result["client_version"] or previous["source"]["assembly_sha256"] != result["source"]["assembly_sha256"]:
raise SystemExit("Cannot merge metadata from different Clients")
previous["floors"].update(floors)
result["floors"] = previous["floors"]
result["source"]["previous_capture_sha256"] = previous["source"].get("previous_capture_sha256", []) + [previous["source"]["capture_sha256"]]
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Exported {len(floors)} floors to {args.output}")