cocos-farm/tools/extract_visible_land_evidence.py

473 lines
19 KiB
Python

#!/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())