Files
GaCy-GO/tools/export_cw_catalog.py

420 lines
28 KiB
Python

"""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("<ID>k__BackingField") != identity[1]
or row.get("<SeasonID>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()