"""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") parser.add_argument("--dimension-index", type=int, help="Use captured SavedValues defaults from this RuntimeFloor dimension-list index") parser.add_argument("--omit-start-anchor", action="store_true", help="Keep per-entrance Anchor.json placements; retain the floor anchor only as floor_default_anchor for entries without StartAnchorID") args = parser.parse_args() floors, completed = {}, set() placements = {} placement_completed = set() props_completed = set() dimensions = {} dimension_groups = {} for line in args.log.read_text(encoding="utf-8", errors="replace").splitlines(): if "[GAC_SCENE_DIMENSION] " in line: row = line.split("[GAC_SCENE_DIMENSION] ", 1)[1] groups = re.fullmatch(r"GROUPS (\d+) (\d+) (\d+) (.+)", row) if groups: floor, index, identity, raw = groups.groups() dimension_groups.setdefault(floor, {})[int(index)] = (int(identity), json.loads(raw)) continue if "[GAC_SCENE_SAVED] " in line: row = line.split("[GAC_SCENE_SAVED] ", 1)[1] dimension = re.fullmatch(r"DIMENSION (\d+) (\d+) (.+)", row) if dimension: floor, index, raw = dimension.groups() dimensions.setdefault(floor, {})[int(index)] = json.loads(raw) continue 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_PROPS] " in line: row = line.split("[GAC_SCENE_PROPS] ", 1)[1] end_props = re.fullmatch(r"END (\d+)", row) prop = re.fullmatch(r"PROP (\d+) (\d+) (\d+) (\d+) (\d+) pos=([^,]+),([^,]+),([^ ]+) rot=(.+)", row) if end_props: props_completed.add(end_props[1]) elif prop: floor, group_id, inst_id, prop_id, state, x, y, z, rot = prop.groups() if floor in floors: 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}, "groupId": int(group_id), "instId": int(inst_id), "propState": int(state), "propId": int(prop_id), } floors[floor].setdefault("props", {}).setdefault(group_id, []).append(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": {}, "props": {}} 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 key not in props_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) for key, floor in floors.items(): if args.omit_start_anchor: anchor = floor.pop("start_anchor", None) if anchor is not None: floor["floor_default_anchor"] = anchor if args.dimension_index is not None: rows = dimensions.get(key, {}).get(args.dimension_index) if not isinstance(rows, list): raise SystemExit(f"Missing SavedValues capture for floor {key}, dimension index {args.dimension_index}; no output written") defaults, rules = {}, {} for row in rows: if row.get("IsDelete"): continue name = row["Name"] value, minimum, maximum = row["DefaultValue"], row["MinValue"], row["MaxValue"] allowed = row.get("AllowedValues", []) if name in defaults or not name or not minimum <= value <= maximum or (allowed and value not in allowed): raise SystemExit(f"Invalid captured SavedValue {key}/{name}; no output written") defaults[name] = value rules[name] = {"DefaultValue": value, "MinValue": minimum, "MaxValue": maximum} floor["saved_values"], floor["saved_value_rules"] = defaults, rules selected = dimension_groups.get(key, {}).get(args.dimension_index) if selected is None: raise SystemExit(f"Missing dimension group membership for floor {key}; no output written") identity, ids = selected if not ids or any(str(group) not in floor["groups"] for group in ids): raise SystemExit(f"Invalid dimension group membership for floor {key}; no output written") floor["dimension_id"] = identity floor["dimension_groups"] = {str(identity): sorted(set(ids))} 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"] # Keep provenance and policies for untouched floors when adding a capture. result["source"] = {**previous["source"], **result["source"]} 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}")