"""Export fully captured Client CW division rows without guessing missing IDs.""" import argparse import hashlib import json import math import pathlib import re def completed_rows(path, prefix): """Use the last complete ROW capture; reject a truncated final capture.""" batch, expected, complete = [], None, False pattern = re.compile(r"\[" + re.escape(prefix) + r"\] (.*)$") for line in path.read_text(encoding="utf-8-sig").splitlines(): match = pattern.search(line) if not match: continue value = match[1] if value.startswith("BEGIN count="): batch, expected, complete = [], int(value.split("=", 1)[1]), False elif value.startswith("ROW "): if expected is None or complete: raise SystemExit(f"{prefix}: row outside active capture") batch.append(json.loads(value[4:])) elif value == "END": complete = True elif value.startswith("ERROR "): complete = False if not complete or not expected or len(batch) != expected: raise SystemExit(f"{prefix}: incomplete final capture: {len(batch)}/{expected}, end={complete}") return batch def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--log", type=pathlib.Path, action="append", required=True) parser.add_argument("--constants-log", type=pathlib.Path) parser.add_argument("--gameplay-log", type=pathlib.Path) parser.add_argument("--entrance-log", type=pathlib.Path) parser.add_argument("--map-entrances", type=pathlib.Path) parser.add_argument("--room-log", type=pathlib.Path) parser.add_argument("--compare-enum-log", type=pathlib.Path) parser.add_argument("--route-log", type=pathlib.Path, required=True) parser.add_argument("--tutorial-log", type=pathlib.Path, required=True) parser.add_argument("--variants-log", type=pathlib.Path) parser.add_argument("--player-level-log", type=pathlib.Path) parser.add_argument("--chapter-log", type=pathlib.Path) parser.add_argument("--node-enum-log", type=pathlib.Path) parser.add_argument("--monster-camp-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) args = parser.parse_args() rows, modules = [], [] for path in args.log: batch, expected, complete = [], None, False for line in path.read_text(encoding="utf-8-sig").splitlines(): module = re.search(r"\[GAC_CW_ACTIVE\] SEASON_ROW (.*)$", line) if module: row = json.loads(module[1]) if row is not None and row not in modules: modules.append(row) match = re.search(r"\[GAC_CW_(?:ACTIVE_)?DIVISIONS\] (.*)$", line) if not match: continue value = match[1] if value.startswith("BEGIN count="): batch, complete = [], False expected = int(value.split("=", 1)[1]) elif value.startswith("ROW "): if expected is None or complete: raise SystemExit("Row outside an active capture") batch.append(json.loads(value[4:])) elif value == "END": complete = True if not complete or not expected or len(batch) != expected: raise SystemExit(f"Incomplete capture {path.name}: expected={expected}, received={len(batch)}, end={complete}") rows.extend(batch) seen = set() for row in rows: for key in ("ID", "SeasonID", "DivisionLevel", "Progress"): if type(row.get(key)) is not int or row[key] < 0: raise SystemExit(f"Invalid {key} in row {row}") if not row["ID"] or not row["SeasonID"] or type(row.get("IsPromotion")) is not bool: raise SystemExit("Invalid division identity/promotion value") if row["ID"] in seen: raise SystemExit(f"Duplicate division ID: {row['ID']}") seen.add(row["ID"]) result = { "client_version": args.client_version, "provenance": { "game_assembly_sha256": hashlib.sha256(args.assembly.read_bytes()).hexdigest(), "captures": [{"sha256": hashlib.sha256(path.read_bytes()).hexdigest()} for path in args.log], "source": "GridFightModule.GetSeason(native season ID, native subseason ID).SeasonDivision.SeasonDivisionConfigs", "scope": "Captured division catalog and constants only; not a completed CW ruleset", }, "captured_seasons": sorted({row["SeasonID"] for row in rows}), "season_modules": modules, "divisions": sorted(rows, key=lambda row: (row["SeasonID"], row["ID"])), } if args.constants_log: constants, ended = {}, set() for line in args.constants_log.read_text(encoding="utf-8-sig").splitlines(): match = re.search(r"\[GAC_CW_CONSTANT\] (?:ROW (\w+) (.*)|END (\w+))$", line) if not match: continue if match[3]: ended.add(match[3]) continue table, row = match[1], json.loads(match[2]) name = row["ConstValueName"] values = constants.setdefault(table, {}) if name in values and values[name] != row["Value"]: raise SystemExit(f"Conflicting captured constant {name}") values[name] = row["Value"] required = {"ConstValueCommonExcelTable", "ConstValueClientExcelTable"} if not required <= ended: raise SystemExit("Constants capture is incomplete: " + ", ".join(sorted(required - ended))) common = constants["ConstValueCommonExcelTable"] season = common["GridFight_Season_ID"]["IntValue"] sub_season = common["GridFight_Sub_Season_ID"]["IntValue"] if season not in result["captured_seasons"]: raise SystemExit("Active season division catalog has not been captured") result["active_season_id"] = season result["active_sub_season_id"] = sub_season if not any(row["SeasonID"] == season and row["SubSeasonID"] == sub_season and row["OfferingID"] > 0 and row["ActivityModuleID"] > 0 for row in modules): raise SystemExit("Active season module metadata has not been captured") result["constants"] = constants result["provenance"]["constants_capture_sha256"] = hashlib.sha256(args.constants_log.read_bytes()).hexdigest() if args.gameplay_log: stages = completed_rows(args.gameplay_log, "GAC_CW_STAGE_CONFIG") roles = completed_rows(args.gameplay_log, "GAC_CW_ROLES") active_divisions = {r["ID"] for r in rows if r["SeasonID"] == result.get("active_season_id")} stage_ids = [r.get("ID") for r in stages] if len(set(stage_ids)) != len(stage_ids) or set(stage_ids) != active_divisions: raise SystemExit("Stage wrapper capture must match the active season division catalog") role_ids = set() for row in roles: config = row.get("Config", {}) if (type(config.get("ID")) is not int or config["ID"] <= 0 or config["ID"] in role_ids or config.get("SeasonID") != result.get("active_season_id")): raise SystemExit("Invalid/duplicate role or role from another season") role_ids.add(config["ID"]) for key in ("Price", "MaxStar", "DefaultStar", "DefaultSellPrice"): if type(row.get(key)) is not int or row[key] < 0: raise SystemExit(f"Invalid role wrapper {key}") if not 0 < row["DefaultStar"] <= row["MaxStar"]: raise SystemExit("Invalid default/max role star") result["active_stage_wrappers"] = sorted(stages, key=lambda r: r["ID"]) result["active_handbook_default_roles"] = sorted(roles, key=lambda r: r["Config"]["ID"]) result["provenance"]["gameplay_capture_sha256"] = hashlib.sha256(args.gameplay_log.read_bytes()).hexdigest() result["provenance"]["gameplay_scope"] = { "stage_wrappers": "GridFightModule.DivisionInfo.GetStagesShown(false); fresh profile, no enabled talents", "roles": "GridFightModule.GetHandBookAllRoleConfig default RoleConfig._Row and wrapper getters; does not enumerate OtherRoles, hidden pool entries or variants", } result["provenance"]["scope"] = "Captured division catalog, constants, active stage wrappers and default handbook roles; not a completed CW ruleset" if args.entrance_log: if not args.map_entrances: raise SystemExit("Entrance export requires the matching MapEntrance table") content = args.entrance_log.read_text(encoding="utf-8-sig") begins = re.findall(r"\[GAC_CW_ENTRANCE_RES\] BEGIN entry=(\d+) groups=(\d+) start_group=(\d+) anchor=(\d+)", content) transforms = re.findall(r"\[GAC_CW_ENTRANCE_RES\] START_TRANSFORM (.*)", content) groups = re.findall(r"\[GAC_CW_ENTRANCE_RES\] START_GROUP (.*)", content) if (len(begins) != 1 or len(transforms) != 1 or len(groups) != 1 or "[GAC_CW_ENTRANCE_RES] END" not in content or "[GAC_CW_ENTRANCE_RES] ERROR" in content): raise SystemExit("Incomplete or ambiguous CW entrance capture") entry_id, count, group_id, anchor_id = map(int, begins[0]) if len(re.findall(r"\[GAC_CW_ENTRANCE_RES\] GROUP ", content)) != count: raise SystemExit("CW entrance group enumeration is incomplete") if entry_id != result["constants"]["ConstValueCommonExcelTable"]["GridFight_EntranceID"]["IntValue"]: raise SystemExit("Captured entrance differs from native CW constant") table = json.loads(args.map_entrances.read_text(encoding="utf-8-sig")) entries = [r for rows in table.values() if isinstance(rows, list) for r in rows if r.get("ID") == entry_id] if len(entries) != 1 or entries[0]["StartGroupID"] != group_id or entries[0]["StartAnchorID"] != anchor_id: raise SystemExit("MapEntrance identity differs from capture") transform, group = json.loads(transforms[0]), json.loads(groups[0]) anchors = [a for a in group["AnchorList"] if a["ID"] == anchor_id and not a["IsDelete"]] if transform["ID"] != group_id or transform["IsDelete"] or len(anchors) != 1: raise SystemExit("Missing or invalid native CW start anchor") anchor = anchors[0] yaw = math.radians(transform["RotY"]) def units(value): if not math.isfinite(value): raise SystemExit("Non-finite CW anchor coordinate") return round(value * 1000) result["entrance"] = { "entry_id": entry_id, "plane_id": entries[0]["PlaneID"], "floor_id": entries[0]["FloorID"], "start_group_id": group_id, "anchor": {"ID": anchor_id, "pos": {"x": units(transform["PosX"] + anchor["PosX"] * math.cos(yaw) + anchor["PosZ"] * math.sin(yaw)), "y": units(transform["PosY"] + anchor["PosY"]), "z": units(transform["PosZ"] - anchor["PosX"] * math.sin(yaw) + anchor["PosZ"] * math.cos(yaw))}, "rot": {"x": 0, "y": units(transform["RotY"] + anchor["RotY"]), "z": 0}}, } result["provenance"]["entrance_capture_sha256"] = hashlib.sha256(args.entrance_log.read_bytes()).hexdigest() result["provenance"]["entrance_source"] = "Native GridFight_EntranceID -> MapEntrance start group/anchor -> RuntimeGroup.AnchorList, world transform; no level graph execution" if args.room_log: if not args.compare_enum_log: raise SystemExit("Room capture requires the native comparison enum capture") enum_text = args.compare_enum_log.read_text(encoding="utf-8-sig") compare_names = {int(value): name for name, value in re.findall(r"\[GAC_CW_SAVED_CONDITION\] ENUM (\w+) (\d+)", enum_text)} room_text = args.room_log.read_text(encoding="utf-8-sig") room_rows, expected, complete = [], None, False for line in room_text.splitlines(): if "[GAC_CW_NAMED_GROUPS] " not in line: continue value = line.split("[GAC_CW_NAMED_GROUPS] ", 1)[1] if value.startswith("BEGIN "): room_rows, expected, complete = [], int(value.split()[1]), False elif value.startswith("GROUP "): _, gid, payload = value.split(" ", 2) room_rows.append((int(gid), json.loads(payload))) elif value == "END": complete = True elif value.startswith("ERROR "): complete = False if not complete or expected != len(room_rows) or len({i for i, _ in room_rows}) != expected: raise SystemExit("Incomplete/duplicate room group capture") native_tags = {t["Tag"] for t in group["TagContainer"]} bindings = [] for gid, row in room_rows: if not native_tags.intersection(t["Tag"] for t in (row["tags"] or [])): continue props = [p for p in row["props"] if not p["deleted"] and not p["client_only"] and p["initial"]] if not props: continue condition = row["saved_value_condition"] if (row["load_condition"] is not None or row["unload_condition"] is not None or row["system_unlock_condition"] is not None or not condition or len(condition["Conditions"]) != 1): raise SystemExit("Room group needs additional condition mapping") c = condition["Conditions"][0] if compare_names.get(c["Operation"]) != "GreaterEqual": raise SystemExit("Unmapped room saved-value comparison") bindings.append({"group_id": gid, "condition": {"name": c["SavedValueName"], "comparison": compare_names[c["Operation"]], "value": c["Value"]}, "props": [{"groupId": gid, "instId": p["id"], "propId": p["prop_id"], "propState": p["state"], "pos": p["pos"], "rot": p["rot"]} for p in props]}) if not bindings: raise SystemExit("No active CW-tagged non-client-only room props captured") result["entrance"]["room_groups"] = bindings saved_rows = [json.loads(value) for value in re.findall(r"\[GAC_CW_NAMED_GROUPS\] SAVED (.*)", room_text)] if not saved_rows or any(rows != saved_rows[0] for rows in saved_rows): raise SystemExit("Missing or inconsistent CW floor saved-value ranges") saved_by_name = {r["Name"]: r for r in saved_rows[0] if not r["IsDelete"]} overrides, rules = {}, {} for binding in bindings: name = binding["condition"]["name"] if name not in saved_by_name: raise SystemExit("CW room condition lacks native saved-value range") rule = saved_by_name[name] value = rule["MaxValue"] if value < binding["condition"]["value"] or (rule["AllowedValues"] and value not in rule["AllowedValues"]): raise SystemExit("Native maximum does not enable CW room condition") overrides[name] = value rules[name] = {k: rule[k] for k in ["DefaultValue", "MinValue", "MaxValue", "AllowedValues"]} result["entrance"]["saved_values"] = overrides result["entrance"]["saved_value_rules"] = rules result["provenance"]["room_unlock_policy"] = "User requested playable CW. On the native CW entrance only, use captured RuntimeDimension MaxValue for the saved-value names referenced by its room activation conditions (PearlSR scene_mgr.zig freeplay policy). This overrides stale inline res.json zeros; not an official quest snapshot. Client-managed groups retain native ownership." result["provenance"]["room_capture_sha256"] = hashlib.sha256(args.room_log.read_bytes()).hexdigest() result["provenance"]["comparison_capture_sha256"] = hashlib.sha256(args.compare_enum_log.read_bytes()).hexdigest() result["provenance"]["room_source"] = "Groups selected by the entrance RuntimeGroup's exact native activity tag. Retain non-deleted, initially loaded, IsClientOnly=false prop metadata and captured saved-value activation condition. Client-managed group graph is not loaded/executed by the exporter. Server applies the saved-value profile, leaving group/prop creation to the Client; room visibility/interaction still needs Client verification." route_table = "GridFightStageRouteConfigExcelTable" route_rows, expected, complete = [], None, False for line in args.route_log.read_text(encoding="utf-8-sig").splitlines(): match = re.search(r"\[GAC_CW_EXCEL\] (.*)$", line) if not match: continue value = match[1] if value.startswith(f"BEGIN {route_table} count="): route_rows, expected, complete = [], int(value.rsplit("=", 1)[1]), False elif value.startswith(f"ROW {route_table} "): if expected is None or complete: raise SystemExit("CW route row outside active capture") route_rows.append(json.loads(value.split(" ", 2)[2])) elif value.startswith(f"END {route_table} count="): complete = expected == int(value.rsplit("=", 1)[1]) elif value.startswith(f"ERROR {route_table}"): raise SystemExit("CW route capture contains an error") if not complete or not expected or len(route_rows) != expected: raise SystemExit("Incomplete CW route capture") keys = set() for row in route_rows: fields = ("ID", "ChapterID", "SectionID", "NodeTemplateID") if any(type(row.get(k)) is not int or not 0 < row[k] <= 0xffffffff for k in fields): raise SystemExit("Invalid CW route identity") key = tuple(row[k] for k in fields[:3]) if key in keys: raise SystemExit(f"Duplicate CW route node {key}") keys.add(key) result["stage_routes"] = sorted(route_rows, key=lambda r: (r["ID"], r["ChapterID"], r["SectionID"])) result["provenance"]["routes_capture_sha256"] = hashlib.sha256(args.route_log.read_bytes()).hexdigest() result["provenance"]["routes_source"] = "RPG.GameCore.Config GridFightStageRouteConfigExcelTable.dataDict; complete native table, without graph execution" tutorials = completed_rows(args.tutorial_log, "GAC_CW_TUTORIAL_CONFIG") tutorial_ids = set() for row in tutorials: for key in ("ID", "DivisionID", "RewardQuest", "ForbiddenBattleFailChapterID", "ForbiddenSellRoleBeforeChapterId", "ForbiddenSellRoleBeforeSectionId"): if type(row.get(key)) is not int or not 0 <= row[key] <= 0xffffffff: raise SystemExit(f"Invalid tutorial {key}") for key in ("HasMonsterReveal", "HasPortal", "HasInitialSupply", "HasRouteShow", "HasEquipRecommendShow", "HasBossToastShow", "UseTrialAvatar"): if type(row.get(key)) is not bool: raise SystemExit(f"Invalid tutorial flag {key}") if row["ID"] == 0 or row["DivisionID"] == 0 or row["ID"] in tutorial_ids: raise SystemExit("Invalid or duplicate tutorial identity") tutorial_ids.add(row["ID"]) if not isinstance(row.get("ForbiddenSellRoleDict"), dict) or not isinstance(row.get("ForbiddenAutoOpenShopNodeList"), list): raise SystemExit("Missing tutorial restrictions") result["tutorials"] = sorted(tutorials, key=lambda r: r["ID"]) result["provenance"]["tutorials_capture_sha256"] = hashlib.sha256(args.tutorial_log.read_bytes()).hexdigest() result["provenance"]["tutorials_source"] = "Initialized GridFightModule.TutorialStageConfigs scalar wrapper rules; no tutorial JsonPath loaded; shop node coordinate fields remain obfuscated" if args.variants_log: variants = completed_rows(args.variants_log, "GAC_CW_ROLE_VARIANTS") unique = {} for row in variants: config, star_config = row.get("Config", {}), row.get("StarConfig", {}) for key in ("ID", "AvatarID", "SeasonID", "Rarity"): if type(config.get(key)) is not int or not 0 < config[key] <= 0xffffffff: raise SystemExit(f"Invalid role variant {key}") for key in ("ParentID", "Price", "MaxStar", "Star", "SellPrice"): if type(row.get(key)) is not int or not 0 <= row[key] <= 0xffffffff: raise SystemExit(f"Invalid role variant wrapper {key}") if (config["SeasonID"] != result["active_season_id"] or row["ParentID"] == 0 or row.get("Origin") not in ("DefaultRole", "OtherRoles") or not 0 < row["Star"] <= row["MaxStar"] or not isinstance(star_config, dict) or not star_config or star_config.get("ID") != config["ID"] or star_config.get("Star") != row["Star"]): raise SystemExit("Role variant identity/star metadata mismatch") key = (config["ID"], row["Star"]) payload = {k: v for k, v in row.items() if k not in ("ParentID", "Origin")} if key in unique and unique[key] != payload: raise SystemExit(f"Conflicting native role variant {key}") unique[key] = payload result["captured_role_variants"] = sorted(unique.values(), key=lambda r: (r["Config"]["ID"], r["Star"])) result["provenance"]["variants_capture_sha256"] = hashlib.sha256(args.variants_log.read_bytes()).hexdigest() result["provenance"]["variants_source"] = "Initialized handbook DefaultRole/OtherRoles wrappers, native role/star/servant rows and price getters; not a claim of complete hidden shop pool" if args.player_level_log: levels = completed_rows(args.player_level_log, "GAC_CW_PLAYER_LEVEL") fields = ("PlayerLevel", "LevelUpExp", "AvatarMaxNumber", "Rarity1Weight", "Rarity2Weight", "Rarity3Weight", "Rarity4Weight", "Rarity5Weight") for row in levels: if any(type(row.get(key)) is not int or not 0 <= row[key] <= 0xffffffff for key in fields): raise SystemExit("Invalid native CW player-level scalar") if row["PlayerLevel"] == 0 or row["AvatarMaxNumber"] == 0 or sum(row[key] for key in fields[3:]) == 0: raise SystemExit("Empty native CW player-level capacity/weights") if not isinstance(row.get("GeneralPropertyList"), list): raise SystemExit("Full player-level row is missing GeneralPropertyList") for prop in row["GeneralPropertyList"]: if (type(prop.get("PropertyType")) is not int or not 0 < prop["PropertyType"] <= 0xffffffff or not isinstance(prop.get("Value"), str) or not re.fullmatch(r"[+-]?[0-9]+(?:\.[0-9]+)?", prop["Value"])): raise SystemExit("Invalid native player-level property value") levels.sort(key=lambda row: row["PlayerLevel"]) rule = result["constants"]["ConstValueCommonExcelTable"]["GridFight_DefaultPlayerLevelMax"] if (not rule.get("IsInt") or [row["PlayerLevel"] for row in levels] != list(range(1, rule["IntValue"] + 1))): raise SystemExit("Player-level capture does not cover the captured default level range") result["captured_player_levels"] = levels result["provenance"]["player_levels_capture_sha256"] = hashlib.sha256(args.player_level_log.read_bytes()).hexdigest() result["provenance"]["player_levels_source"] = "Native GridFightPlayerLevelConfigRow getter at RVA 0x1e3a7880 and named JsonConvert.SerializeObject via RuntimeMethodHandle on managed Client thread; complete serialized rows including GeneralPropertyList; scalar identity cross-check, no run mutation" if args.chapter_log or args.node_enum_log: if not args.chapter_log or not args.node_enum_log: raise SystemExit("Supply both chapter and node-enum captures") chapters = completed_rows(args.chapter_log, "GAC_CW_CHAPTER_CONFIG") for row in chapters: if (type(row.get("ChapterID")) is not int or not 0 < row["ChapterID"] <= 0xffffffff or type(row.get("HealNum")) is not int or not 0 <= row["HealNum"] <= 0xffffffff): raise SystemExit("Invalid native CW chapter row") if (len({row["ChapterID"] for row in chapters}) != len(chapters) or {row["ChapterID"] for row in chapters} != {row["ChapterID"] for row in route_rows}): raise SystemExit("Chapter capture does not cover every route chapter exactly once") enums = completed_rows(args.node_enum_log, "GAC_CW_NODE_ENUM") if (any(not isinstance(row.get("name"), str) or not row["name"] or type(row.get("value")) is not int or not 0 <= row["value"] <= 0xffffffff for row in enums) or len({row["value"] for row in enums}) != len(enums) or len({row["name"] for row in enums}) != len(enums)): raise SystemExit("Invalid/duplicate native CW node type enum") result["chapters"] = sorted(chapters, key=lambda row: row["ChapterID"]) result["node_types"] = sorted(enums, key=lambda row: row["value"]) result["provenance"]["chapters_capture_sha256"] = hashlib.sha256(args.chapter_log.read_bytes()).hexdigest() result["provenance"]["node_types_capture_sha256"] = hashlib.sha256(args.node_enum_log.read_bytes()).hexdigest() result["provenance"]["chapters_source"] = "Native table getter used by GridFightStageNode._RefreshCachedFields with route ChapterID, serialized on managed Client thread; complete captured-route chapter coverage" result["provenance"]["node_types_source"] = "Exact named RPG.GameCore.GridFightNodeType enum metadata" if args.monster_camp_log: camps, seen = [], set() for row in completed_rows(args.monster_camp_log, "GAC_CW_MONSTER_CAMP"): base, season = row.get("_row"), row.get("_seasonRow") if not isinstance(base, dict) or not isinstance(season, dict): raise SystemExit("Missing native camp/season rows") identity = (season.get("SeasonID"), base.get("ID")) if (any(type(v) is not int or not 0 < v <= 0xffffffff for v in identity) or identity[0] != result["active_season_id"] or identity in seen or season.get("CampID") != identity[1] or row.get("k__BackingField") != identity[1] or row.get("k__BackingField") != identity[0]): raise SystemExit("Invalid or duplicate CW camp identity") for source in (base, season): if (not isinstance(source.get("MonsterList"), list) or not source["MonsterList"] or any(type(v) is not int or not 0 < v <= 0xffffffff for v in source["MonsterList"])): raise SystemExit("Invalid native camp monster list") if (not isinstance(base.get("BattleAreaList"), list) or any(type(v) is not int or not 0 < v <= 0xffffffff for v in base["BattleAreaList"])): raise SystemExit("Invalid native camp battle area list") for key in ("BossBattleArea", "InitialRandomCode", "IfRandomEnabled"): if type(base.get(key)) is not int or not 0 <= base[key] <= 0xffffffff: raise SystemExit(f"Invalid camp {key}") seen.add(identity) camps.append({"ID":identity[1], "SeasonID":identity[0], "Common":base, "Season":season}) result["captured_monster_camps"] = sorted(camps, key=lambda row:(row["SeasonID"],row["ID"])) result["provenance"]["monster_camps_capture_sha256"] = hashlib.sha256(args.monster_camp_log.read_bytes()).hexdigest() result["provenance"]["monster_camps_source"] = "Initialized GetAllMonsterCampData handbook configurations; native common/season rows, no battle-template loader or scene graph; capture does not establish the full hidden camp pool or wave recipes" args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"Exported {len(rows)} division rows; seasons={result['captured_seasons']}") if __name__ == "__main__": main()