Files

419 lines
28 KiB
Python
Raw Permalink Normal View History

"""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()