#!/usr/bin/env python3 """Extract Stage 1B visible land evidence from the original Cocos source. This pass intentionally uses farm_scene_v3, not farm_scene_v3_sprite. The sprite prefab contains 36 static visual cells, while the live land config and the mounted farm_scene_v3 prefab contain the 24 business lands used by runtime. """ from __future__ import annotations import json from pathlib import Path from typing import Any from PIL import Image ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = Path("/Users/hy/Documents/dev/qq_farm_godot_rebuild_20260616") MAIN_ELEMENTS = SOURCE_ROOT / "source_first" / "out" / "main_scene_elements.json" FARM_SCENE_PREFAB_RAW = SOURCE_ROOT / "downloads/raw/remote/extraRes/import/0e/0ec3b3100.19268.json" LAND_CONFIG = SOURCE_ROOT / "downloads/raw/remote/delayRes/import/47/47eb608b-eae8-46d2-b8b3-b128522275d0.5c42d.json" LEVEL_CONFIG = SOURCE_ROOT / "downloads/raw/remote/delayRes/import/d3/d3d0dbbe-6b4e-47de-8f1a-b73d8296662e.f3b53.json" WEATHER_CONFIG = SOURCE_ROOT / "downloads/raw/remote/delayRes/import/02/02901df2-cd47-4d3a-bf6f-7570a5c27387.4de34.json" CONVERTED_IMAGES = SOURCE_ROOT / "downloads" / "converted_png" / "images" SPINE_REGION_IMAGES = SOURCE_ROOT / "downloads" / "spine_regions" / "images" SCENE_LAND_ATLAS = SOURCE_ROOT / "downloads/raw/remote/extraRes/native/ef/ef013316-a58f-4146-aaaf-962f3b31e11b.592d9.atlas" SCENE_LAND_ATLAS_IMAGE = ( SOURCE_ROOT / "downloads/converted_png/images/extraRes__spine__v2__scene__scene_land__scene_land__df2cacd7-f461-49e3-a50e-2e8a8bf76000.37b48.png" ) EXTRARES_IMPORT = SOURCE_ROOT / "downloads/raw/remote/extraRes/import" OUT_PATH = ROOT / "source_export" / "stage1_visible_lands_source.json" REPORT_PATH = ROOT / "source_export" / "stage1_visible_lands_extract_report.json" LAND_ASSET_TERMS = [ "land_valid1", "land_valid2", "land_valid3", "land_valid4", "land_dry1", "land_dry2", "land_dry3", "land_dry4", "land_extend", "land_locked", ] SPINE_REGION_ASSETS = { "land_valid1", "land_valid2", "land_valid3", "land_valid4", "land_dry1", "land_dry2", "land_dry3", "land_dry4", "land_locked", } def load_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def compact_json_asset_records(path: Path) -> list[dict[str, Any]]: data = load_json(path) return data[5][0][2] def vec3(value: dict[str, Any]) -> dict[str, float]: return {"x": float(value.get("x", 0)), "y": float(value.get("y", 0)), "z": float(value.get("z", 0))} def vec2_from_size(value: dict[str, Any] | None) -> dict[str, float]: if not value: return {"x": 0.0, "y": 0.0} return {"x": float(value.get("width", value.get("x", 0))), "y": float(value.get("height", value.get("y", 0)))} def vec2(value: dict[str, Any] | None, default_x: float = 0.5, default_y: float = 0.5) -> dict[str, float]: if not value: return {"x": default_x, "y": default_y} return {"x": float(value.get("x", default_x)), "y": float(value.get("y", default_y))} def find_metadata_dict(obj: Any, name: str) -> dict[str, Any] | None: if isinstance(obj, dict): if obj.get("name") == name and "rect" in obj and "originalSize" in obj: return obj for value in obj.values(): found = find_metadata_dict(value, name) if found: return found elif isinstance(obj, list): for value in obj: found = find_metadata_dict(value, name) if found: return found return None def find_sprite_metadata(name: str) -> tuple[Path, dict[str, Any]]: for path in sorted(EXTRARES_IMPORT.rglob("*.json")): text = path.read_text(encoding="utf-8", errors="ignore") if f'"name":"{name}"' not in text and f'"name": "{name}"' not in text: continue found = find_metadata_dict(json.loads(text), name) if found: return path, found raise FileNotFoundError(f"missing spriteFrame metadata for {name}") def find_converted_png(name: str) -> Path: matches = sorted(CONVERTED_IMAGES.glob(f"extraRes__model__v3__{name}__*.png")) if not matches: raise FileNotFoundError(f"missing converted png for {name}") return matches[0] def find_spine_region_png(name: str) -> Path: matches = sorted(SPINE_REGION_IMAGES.glob(f"spine__v2__scene__scene_land__scene_land__{name}.png")) if not matches: raise FileNotFoundError(f"missing scene_land spine region png for {name}") return matches[0] def read_atlas_region(name: str) -> dict[str, Any]: lines = SCENE_LAND_ATLAS.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): if line.strip() != name: continue region: dict[str, Any] = {"name": name} for raw in lines[index + 1 : index + 8]: if not raw.startswith(" "): break key, value = raw.strip().split(":", 1) value = value.strip() if key in {"rotate"}: region[key] = value == "true" elif key in {"xy", "size", "orig", "offset"}: left, right = [int(item.strip()) for item in value.split(",")] region[key] = {"x": left, "y": right} if key in {"xy", "offset"} else {"width": left, "height": right} elif key == "index": region[key] = int(value) else: region[key] = value return region raise KeyError(f"missing atlas region {name}") def scene_land_skeleton_data(farm_prefab_raw: list[Any]) -> dict[str, Any]: """Extract the exact scene_land SkeletonData payload embedded in farm_scene_v3.""" record = farm_prefab_raw[5][0][0][0] if record[1] != "scene_land": raise ValueError(f"unexpected first SkeletonData record: {record[1]}") skeleton_json = record[4] animations = sorted(skeleton_json.get("animations", {}).keys()) return { "source": str(FARM_SCENE_PREFAB_RAW), "recordTemplateIndex": record[0], "name": record[1], "atlasText": record[2], "textureNames": record[3], "skeletonJson": skeleton_json, "sourceTextureRefs": record[5], "atlasPath": str(SCENE_LAND_ATLAS), "atlasImagePath": str(SCENE_LAND_ATLAS_IMAGE), "skeleton": skeleton_json.get("skeleton", {}), "skins": [item.get("name") for item in skeleton_json.get("skins", [])], "animations": animations, } def icon_skeleton_components(farm_prefab_raw: list[Any]) -> list[dict[str, Any]]: icons: list[dict[str, Any]] = [] for row in farm_prefab_raw[5][1][0]: if not isinstance(row, list) or len(row) < 4 or row[1] != "icon": continue skeleton = row[3][1] if skeleton[0] == 10: icons.append( { "sourceIconOrder": len(icons), "compactTemplate": 10, "defaultSkin": "", "preCacheMode": skeleton[1], "cacheMode": skeleton[2], "enableBatch": skeleton[3], "skeletonDataRef": skeleton[6], } ) elif skeleton[0] == 39: icons.append( { "sourceIconOrder": len(icons), "compactTemplate": 39, "defaultSkin": skeleton[1], "preCacheMode": skeleton[2], "cacheMode": skeleton[3], "enableBatch": skeleton[4], "skeletonDataRef": skeleton[7], } ) else: raise ValueError(f"unexpected icon skeleton compact template {skeleton[0]}") return icons def build_asset_records() -> list[dict[str, Any]]: assets: list[dict[str, Any]] = [] for name in LAND_ASSET_TERMS: if name in SPINE_REGION_ASSETS: image_path = find_spine_region_png(name) atlas_region = read_atlas_region(name) metadata_path = SCENE_LAND_ATLAS metadata = { "rect": {"x": 0, "y": 0, "width": atlas_region["orig"]["width"], "height": atlas_region["orig"]["height"]}, "originalSize": atlas_region["orig"], "offset": atlas_region["offset"], "pivot": {"x": 0.5, "y": 0.5}, "rotated": atlas_region["rotate"], "capInsets": [0, 0, 0, 0], } render_source = "spine_region" source_resource = f"spine/v2/scene/scene_land/scene_land:{name}" spine = { "skeletonResource": "spine/v2/scene/scene_land/scene_land", "regionName": name, "atlasPath": str(SCENE_LAND_ATLAS), "atlasRegion": atlas_region, } else: image_path = find_converted_png(name) metadata_path, metadata = find_sprite_metadata(name) render_source = "sprite_frame" source_resource = f"model/v3/{name}/spriteFrame" spine = None with Image.open(image_path) as image: pixel_width, pixel_height = image.size rect = metadata["rect"] original = metadata["originalSize"] pivot = metadata.get("pivot", {"x": 0.5, "y": 0.5}) assets.append( { "id": name, "sourcePath": str(image_path), "metadataPath": str(metadata_path), "sourceResource": source_resource, "renderSource": render_source, "spine": spine, "rect": { "x": float(rect["x"]), "y": float(rect["y"]), "width": float(rect["width"]), "height": float(rect["height"]), }, "originalSize": { "width": float(original["width"]), "height": float(original["height"]), }, "offset": { "x": float(metadata.get("offset", {}).get("x", 0)), "y": float(metadata.get("offset", {}).get("y", 0)), }, "pivot": {"x": float(pivot.get("x", 0.5)), "y": float(pivot.get("y", 0.5))}, "rotated": bool(metadata.get("rotated", False)), "capInsets": metadata.get("capInsets", [0, 0, 0, 0]), "pixelSize": {"width": pixel_width, "height": pixel_height}, } ) return assets def element_by_path(elements: list[dict[str, Any]], path: str) -> dict[str, Any]: for element in elements: if element.get("full_path") == path: return element raise KeyError(path) def compact_node(element: dict[str, Any]) -> dict[str, Any]: return { "sourceIndex": element["index"], "name": element["name"], "path": element["full_path"], "parentPath": element.get("parent_path"), "category": element.get("category"), "localPosition": vec3(element["local_position"]), "localScale": vec3(element["local_scale"]), "localEuler": vec3(element["local_euler"]), "worldPosition": vec3(element["world_position_transformed"]), "size": vec2_from_size(element.get("primary_size")), "anchor": vec2(element.get("anchor"), 0.5, 0.5), "componentCount": int(element.get("component_count", 0)), "resolvedParentSource": element.get("resolved_parent_source"), } def mock_land_asset(record: dict[str, Any]) -> dict[str, Any]: land_id = int(record["id"]) if land_id <= 6: return {"state": "unlocked_normal", "landLevel": 1, "assetId": "land_valid1", "evidence": "Level.id=1.land_res"} if land_id == 7: return { "state": "first_expandable", "landLevel": 0, "assetId": "land_extend", "evidence": "Land.id=7.unlocked_res; first record with preceding_land_id and level_need=5", } return {"state": "locked_unplowed", "landLevel": 0, "assetId": "land_locked", "evidence": "Weather.id=1.lockLand"} def main() -> int: main_elements = load_json(MAIN_ELEMENTS) farm_prefab_raw = load_json(FARM_SCENE_PREFAB_RAW) prefab = main_elements["prefabs"]["farm_scene_v3"] elements = prefab["elements"] sprite_prefab = main_elements["prefabs"]["farm_scene_v3_sprite"] cells = [compact_node(item) for item in elements if item.get("category") == "visual_land_cell"] cells_by_grid = {item["name"]: item for item in cells} icons = { item["parent_path"].rsplit("/", 1)[-1]: compact_node(item) for item in elements if item.get("full_path", "").startswith("farm_scene_v3/Scaled/Rotate/GridOrigin/") and item.get("full_path", "").endswith("/icon") } land_config = compact_json_asset_records(LAND_CONFIG) level_config = compact_json_asset_records(LEVEL_CONFIG) weather_config = compact_json_asset_records(WEATHER_CONFIG) sunny_weather = next(item for item in weather_config if item["id"] == 1) land_level_asset_rules = [ { "id": item["id"], "landLevel": item["land_level"], "levelName": item["level_name"], "landRes": item["land_res"], "landDryRes": item["land_dryRes"], "mergeAnim": item["merge_anim"], "source": "Level JsonAsset", } for item in level_config if item.get("id") in [1, 2, 3, 4, 5] ] visible_lands: list[dict[str, Any]] = [] for record in land_config: grid_name = f"grid_{record['grid_x']}_{record['grid_y']}" cell = cells_by_grid[grid_name] icon = icons[grid_name] visible_lands.append( { "landId": int(record["id"]), "gridName": grid_name, "config": record, "cell": cell, "icon": icon, "mockRender": mock_land_asset(record), } ) evidence = { "metadata": { "module": "stage1_visible_lands", "sourceFiles": [ str(MAIN_ELEMENTS), str(FARM_SCENE_PREFAB_RAW), str(LAND_CONFIG), str(LEVEL_CONFIG), str(WEATHER_CONFIG), str(SCENE_LAND_ATLAS), str(SCENE_LAND_ATLAS_IMAGE), ], "sourcePackage": str(SOURCE_ROOT), "sourceRule": "Use farm_scene_v3 24 visual_land_cell nodes. farm_scene_v3_sprite has 36 static cells and is recorded only as a non-authoritative comparison source for this pass.", }, "excludedStaticGrid": { "prefab": "farm_scene_v3_sprite", "visualLandCellCount": int(sprite_prefab["category_counts"].get("visual_land_cell", 0)), "reason": "This prefab serializes 36 static grid cells under a rotated/scaled GridOrigin; Land config and live farm_scene_v3 only define 24 lands.", }, "landRenderEvidence": { "farmScenePrefabTypeNames": [ item[0] if isinstance(item, list) else item for item in farm_prefab_raw[3] if (isinstance(item, list) and item) or isinstance(item, str) ], "farmSceneFieldNames": farm_prefab_raw[2], "farmSceneSkeletonComponentFields": farm_prefab_raw[3][2][1], "farmSceneSkeletonDataFields": farm_prefab_raw[3][7][1], "iconSkeletonCompactTemplates": { "regularIconTemplate": farm_prefab_raw[4][10], "explicitDefaultSkinTemplate": farm_prefab_raw[4][39], }, "rule": "farm_scene_v3 serializes each land icon with sp.Skeleton. Its embedded scene_land SkeletonData contains atlasText, textureNames, skeletonJson, and animations; runtime land state selects Level.merge_anim or Weather.lockLand animation. Stage 1B must generate sp.Skeleton components instead of replacing them with cc.Sprite tiles.", }, "sceneLandSkeletonData": scene_land_skeleton_data(farm_prefab_raw), "iconSkeletonComponents": icon_skeleton_components(farm_prefab_raw), "gridRoot": { "prefab": "farm_scene_v3", "visualLandCellCount": len(cells), "transformNodes": [ compact_node(element_by_path(elements, "farm_scene_v3/Scaled")), compact_node(element_by_path(elements, "farm_scene_v3/Scaled/Rotate")), compact_node(element_by_path(elements, "farm_scene_v3/Scaled/Rotate/GridOrigin")), ], }, "landConfig": { "path": str(LAND_CONFIG), "count": len(land_config), "records": land_config, "gridMappingRule": "grid_x/grid_y maps directly to farm_scene_v3/Scaled/Rotate/GridOrigin/grid_{grid_x}_{grid_y}.", }, "levelConfig": { "path": str(LEVEL_CONFIG), "landLevelAssetRules": land_level_asset_rules, }, "weatherConfig": { "path": str(WEATHER_CONFIG), "sunnyLockLand": sunny_weather["lockLand"], }, "assets": build_asset_records(), "visibleLands": visible_lands, "mockRuntimeState": { "separationRule": "These values drive the local preview only. They do not create or modify source layout.", "currentUnlockedLandIds": [1, 2, 3, 4, 5, 6], "defaultUnlockedLandLevel": 1, "landLevelSourceRule": "Actual land-level-to-sprite mapping is read from Level.land_res and Level.land_dryRes. No red/black/gold land is assigned without runtime land-level state evidence.", "firstExpandableLandId": 7, "lockedLandIds": list(range(8, 25)), }, } errors: list[str] = [] if len(cells) != 24: errors.append(f"farm_scene_v3 visual_land_cell count expected 24, got {len(cells)}") if len(icons) != 24: errors.append(f"farm_scene_v3 icon count expected 24, got {len(icons)}") if len(evidence["iconSkeletonComponents"]) != 24: errors.append(f"farm_scene_v3 icon skeleton count expected 24, got {len(evidence['iconSkeletonComponents'])}") if len(land_config) != 24: errors.append(f"Land config record count expected 24, got {len(land_config)}") if len(visible_lands) != 24: errors.append(f"visible lands expected 24, got {len(visible_lands)}") missing = [path for path in evidence["metadata"]["sourceFiles"] if not Path(path).exists()] missing.extend(asset["sourcePath"] for asset in evidence["assets"] if not Path(asset["sourcePath"]).exists()) missing.extend(asset["metadataPath"] for asset in evidence["assets"] if not Path(asset["metadataPath"]).exists()) write_json(OUT_PATH, evidence) report = { "status": "pass" if not errors and not missing else "needs_solution", "sourceEvidence": str(OUT_PATH), "visibleLandCount": len(visible_lands), "sourceGridCount": len(cells), "excludedStaticGridCount": evidence["excludedStaticGrid"]["visualLandCellCount"], "assetCount": len(evidence["assets"]), "errors": errors, "missing": missing, } write_json(REPORT_PATH, report) print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if report["status"] == "pass" else 1 if __name__ == "__main__": raise SystemExit(main())