cocos-farm/tools/extract_top_hud_evidence.py

507 lines
23 KiB
Python

#!/usr/bin/env python3
"""Extract source-backed evidence for the top HUD pass.
This pass is intentionally limited to the original Cocos `main_ui_v2` nodes
needed for the top-left exit button, user information, experience text, gold,
and diamond display. Mock user values are recorded separately from source
layout and source SpriteFrame metadata.
"""
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")
RAW_GAME = SOURCE_ROOT / "source_raw/6A3BB7D3D4349E2050E7B701AAD51600_726750140f8084ebcbe5190fe9c198c9"
MAIN_ELEMENTS = SOURCE_ROOT / "source_first/out/main_scene_elements.json"
MAIN_UI_PREFAB = SOURCE_ROOT / "downloads/raw/remote/extraRes/import/0d/0d7027270.4bc05.json"
EXTRARES_CONFIG = SOURCE_ROOT / "downloads/raw/remote/extraRes/config.229e7.json"
V2_ATLAS_IMPORT = SOURCE_ROOT / "downloads/raw/remote/extraRes/import/03/03f07fafe.d2787.json"
V2_ATLAS_NATIVE = SOURCE_ROOT / "downloads/raw/remote/extraRes/native/17/17b0f7c92.2875b.astc"
SPRITEFRAME_IMAGES = SOURCE_ROOT / "downloads/spriteframes/images"
MOCK_AVATAR = RAW_GAME / "openDataContext/render/avatar.png"
OUT_PATH = ROOT / "source_export/stage1_top_hud_source.json"
REPORT_PATH = ROOT / "source_export/stage1_top_hud_extract_report.json"
V2_ASSETS = {
"btn_quit": "gui/texture/v2/btn_quit/spriteFrame",
"img_head_di": "gui/texture/v2/img_head_di/spriteFrame",
"img_head_di1": "gui/texture/v2/img_head_di1/spriteFrame",
"img_level_bar": "gui/texture/v2/img_level_bar/spriteFrame",
"img_level": "gui/texture/v2/img_level/spriteFrame",
"img_source_di_v2": "gui/texture/v2/img_source_di_v2/spriteFrame",
"img_gold": "gui/texture/v2/img_gold/spriteFrame",
"img_diamond": "gui/texture/v2/img_diamond/spriteFrame",
}
LABEL_NODE_IDS = {
94: ("txtName", "nickname"),
95: ("txtLevel", "level"),
96: ("txtExp", "experience"),
98: ("txtGold", "goldDisplay"),
99: ("txtDiamond", "diamond"),
}
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: 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 image_info(path: Path) -> dict[str, Any]:
with Image.open(path) as image:
bbox = image.getbbox()
return {
"width": image.size[0],
"height": image.size[1],
"mode": image.mode,
"alphaBoundingBox": list(bbox) if bbox else None,
}
def element_by_path(elements: list[dict[str, Any]], path: str) -> dict[str, Any]:
for item in elements:
if item.get("full_path") == path:
return item
raise KeyError(path)
def vec3(value: dict[str, Any] | None) -> dict[str, float]:
if not value:
return {"x": 0.0, "y": 0.0, "z": 0.0}
return {"x": float(value.get("x", 0)), "y": float(value.get("y", 0)), "z": float(value.get("z", 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 node_from_element(
elements: list[dict[str, Any]],
path: str,
*,
size: dict[str, float] | None = None,
anchor: dict[str, float] | None = None,
sprite_asset_id: str | None = None,
sprite_type: int = 0,
label: dict[str, Any] | None = None,
mask: dict[str, Any] | None = None,
progress_bar: dict[str, Any] | None = None,
widget: dict[str, Any] | None = None,
source_note: str | None = None,
) -> dict[str, Any]:
element = element_by_path(elements, path)
result = {
"sourceIndex": int(element["index"]),
"path": element["full_path"],
"name": element["name"],
"parentPath": element.get("parent_path"),
"localPosition": vec3(element.get("local_position")),
"localScale": vec3(element.get("local_scale")),
"localEuler": vec3(element.get("local_euler")),
"worldPosition": vec3(element.get("world_position_transformed")),
"size": size
or (
{"x": float(element["primary_size"]["width"]), "y": float(element["primary_size"]["height"])}
if element.get("primary_size")
else None
),
"anchor": anchor or (vec2(element.get("anchor")) if element.get("anchor") else None),
"active": True if element.get("active") is None else bool(element.get("active")),
"componentCount": int(element.get("component_count", 0)),
"spriteAssetId": sprite_asset_id,
"spriteType": sprite_type,
"label": label,
"mask": mask,
"progressBar": progress_bar,
"widget": widget,
"sourceNote": source_note,
}
return {key: value for key, value in result.items() if value is not None}
def decode_compact(prefab: list[Any], value: Any) -> dict[str, Any] | None:
if not isinstance(value, list) or not value or not isinstance(value[0], int):
return None
template_id = value[0]
templates = prefab[4]
classes = prefab[3]
if template_id < 0 or template_id >= len(templates):
return None
template = templates[template_id]
if not template or not isinstance(template[0], int):
return None
class_index = template[0]
if class_index < 0 or class_index >= len(classes):
return None
class_def = classes[class_index]
fields = class_def[1] if len(class_def) > 1 and isinstance(class_def[1], list) else []
props: dict[str, Any] = {}
for prop_index, prop_value in zip(template[1:], value[1:]):
if isinstance(prop_index, int) and 0 <= prop_index < len(fields):
props[fields[prop_index]] = prop_value
return {"type": class_def[0], "template": template_id, "props": props, "raw": value}
def collect_compact(prefab: list[Any], value: Any, result: list[dict[str, Any]]) -> None:
decoded = decode_compact(prefab, value)
if decoded:
result.append(decoded)
if isinstance(value, list):
for item in value:
collect_compact(prefab, item, result)
elif isinstance(value, dict):
for item in value.values():
collect_compact(prefab, item, result)
def packed_color(value: list[Any] | None, fallback: int) -> int:
if isinstance(value, list) and len(value) == 2 and value[0] == 4:
return int(value[1])
return fallback
def extract_labels(prefab: list[Any]) -> dict[int, dict[str, Any]]:
decoded: list[dict[str, Any]] = []
collect_compact(prefab, prefab[5][2], decoded)
labels: dict[int, dict[str, Any]] = {}
for item in decoded:
if item["type"] != "cc.Label":
continue
props = item["props"]
node_id = props.get("node")
if node_id not in LABEL_NODE_IDS:
continue
_, mock_field = LABEL_NODE_IDS[int(node_id)]
labels[int(node_id)] = {
"sourceNodeIndex": int(node_id),
"mockField": mock_field,
"string": str(props.get("_string", "")),
"actualFontSize": float(props.get("_actualFontSize", props.get("_fontSize", 20))),
"fontSize": float(props.get("_fontSize", props.get("_actualFontSize", 20))),
"lineHeight": float(props.get("_lineHeight", props.get("_fontSize", 20))),
"horizontalAlign": int(props.get("_horizontalAlign", 1)),
"verticalAlign": int(props.get("_verticalAlign", 1)),
"overflow": int(props.get("_overflow", 0)),
"enableWrapText": bool(props.get("_enableWrapText", True)),
"bold": bool(props.get("_isBold", False)),
"enableOutline": bool(props.get("_enableOutline", False)),
"colorUint32": packed_color(props.get("_color"), 0xFFFFFFFF),
"outlineColorUint32": packed_color(props.get("_outlineColor"), 0xFFFFFFFF),
"sourceTemplate": item["template"],
"sourceRaw": item["raw"],
}
return labels
def extract_progress_bar(prefab: list[Any]) -> dict[str, Any]:
decoded: list[dict[str, Any]] = []
collect_compact(prefab, prefab[5][2], decoded)
for item in decoded:
if item["type"] == "cc.ProgressBar" and item["props"].get("node") == 44:
props = item["props"]
return {
"sourceNodeIndex": 44,
"componentType": "cc.ProgressBar",
"totalLength": float(props["_totalLength"]),
"progress": float(props["_progress"]),
"barSpriteSourceRef": props.get("_barSprite"),
"sourceTemplate": item["template"],
"sourceRaw": item["raw"],
}
raise RuntimeError("Source cc.ProgressBar for expBar was not found.")
def extract_widget_from_node(prefab: list[Any], source_node_index: int) -> dict[str, Any]:
# The compact main_ui_v2 object table keeps source node rows in
# prefab[5][2][0].
# A node's component array contains inline component records; the Widget
# component's `node` field is a negative back-reference generated by Cocos,
# so ownership must be resolved by reading the component list of the source
# node row itself.
try:
node_record = prefab[5][2][0][source_node_index]
except IndexError as exc:
raise RuntimeError(f"Source node row {source_node_index} is missing from main_ui_v2 prefab.") from exc
decoded_node = decode_compact(prefab, node_record)
if not decoded_node or decoded_node["type"] != "cc.Node":
raise RuntimeError(f"Source row {source_node_index} is not a cc.Node.")
compressed_components = decoded_node["props"].get("_components", [])
components = compressed_components[0] if compressed_components and isinstance(compressed_components[0], list) else compressed_components
for component in components:
decoded_component = decode_compact(prefab, component)
if decoded_component and decoded_component["type"] == "cc.Widget":
props = decoded_component["props"]
return {
"componentType": "cc.Widget",
"sourceNodeIndex": source_node_index,
"alignFlags": int(props.get("_alignFlags", 0)),
"alignMode": int(props.get("_alignMode", 0)),
"left": float(props["_left"]) if "_left" in props else None,
"right": float(props["_right"]) if "_right" in props else None,
"top": float(props["_top"]) if "_top" in props else None,
"bottom": float(props["_bottom"]) if "_bottom" in props else None,
"originalWidth": float(props["_originalWidth"]) if "_originalWidth" in props else None,
"originalHeight": float(props["_originalHeight"]) if "_originalHeight" in props else None,
"sourceTemplate": decoded_component["template"],
"sourceRaw": decoded_component["raw"],
}
raise RuntimeError(f"Source cc.Widget for node row {source_node_index} was not found.")
def extract_ui_transform_from_node(prefab: list[Any], source_node_index: int) -> dict[str, Any]:
try:
node_record = prefab[5][2][0][source_node_index]
except IndexError as exc:
raise RuntimeError(f"Source node row {source_node_index} is missing from main_ui_v2 prefab.") from exc
decoded_node = decode_compact(prefab, node_record)
if not decoded_node or decoded_node["type"] != "cc.Node":
raise RuntimeError(f"Source row {source_node_index} is not a cc.Node.")
compressed_components = decoded_node["props"].get("_components", [])
components = compressed_components[0] if compressed_components and isinstance(compressed_components[0], list) else compressed_components
for component in components:
decoded_component = decode_compact(prefab, component)
if decoded_component and decoded_component["type"] == "cc.UITransform":
props = decoded_component["props"]
raw_size = props.get("_contentSize")
raw_anchor = props.get("_anchorPoint")
has_serialized_size = isinstance(raw_size, list) and len(raw_size) >= 3
has_serialized_anchor = isinstance(raw_anchor, list) and len(raw_anchor) >= 3
return {
"componentType": "cc.UITransform",
"sourceNodeIndex": source_node_index,
"size": {
"x": float(raw_size[1]) if has_serialized_size else 100.0,
"y": float(raw_size[2]) if has_serialized_size else 100.0,
},
"anchor": {
"x": float(raw_anchor[1]) if has_serialized_anchor else 0.5,
"y": float(raw_anchor[2]) if has_serialized_anchor else 0.5,
},
"usesEngineDefaults": not has_serialized_size or not has_serialized_anchor,
"engineDefaultEvidence": (
"Cocos Creator 3.8 UITransform initializes _contentSize to Size(100,100) "
"and _anchorPoint to Vec2(0.5,0.5) when the compact component omits those fields."
),
"sourceTemplate": decoded_component["template"],
"sourceRaw": decoded_component["raw"],
}
raise RuntimeError(f"Source cc.UITransform for node row {source_node_index} was not found.")
def sprite_frame_by_name(atlas_data: list[Any], name: str) -> tuple[int, dict[str, Any]]:
for index, row in enumerate(atlas_data[5]):
frame = row[0][0] if isinstance(row, list) and row and isinstance(row[0], list) and row[0] else None
if isinstance(frame, dict) and frame.get("name") == name:
return index, frame
raise KeyError(name)
def config_entry_for_asset(config: dict[str, Any], asset_path: str) -> tuple[int, str]:
for raw_index, value in config["paths"].items():
if value[0] == asset_path:
index = int(raw_index)
return index, config["uuids"][index]
raise KeyError(asset_path)
def asset_record(config: dict[str, Any], atlas_data: list[Any], asset_id: str, resource: str) -> dict[str, Any]:
config_index, compressed_uuid = config_entry_for_asset(config, resource)
frame_name = resource.split("/")[-2]
payload_index, frame = sprite_frame_by_name(atlas_data, frame_name)
output = SPRITEFRAME_IMAGES / f"extraRes__gui__texture__v2__{frame_name}__03f07fafe_{payload_index}.png"
return {
"id": asset_id,
"bundle": "extraRes",
"resource": resource,
"configIndex": config_index,
"compressedUuid": compressed_uuid,
"atlasPackKey": "03f07fafe",
"sourceMetadataFile": str(V2_ATLAS_IMPORT),
"sourceAtlasFile": str(V2_ATLAS_NATIVE),
"sourcePath": str(output),
"spriteFrame": frame,
"pixelSize": image_info(output),
}
def main() -> int:
main_elements = load_json(MAIN_ELEMENTS)
elements = main_elements["prefabs"]["main_ui_v2"]["elements"]
prefab = load_json(MAIN_UI_PREFAB)
config = load_json(EXTRARES_CONFIG)
atlas = load_json(V2_ATLAS_IMPORT)
labels = extract_labels(prefab)
progress = extract_progress_bar(prefab)
main_widget = extract_widget_from_node(prefab, 1)
head_info_widget = extract_widget_from_node(prefab, 7)
source_widget = extract_widget_from_node(prefab, 31)
source_ui_transform = extract_ui_transform_from_node(prefab, 31)
source_files = [
MAIN_ELEMENTS,
MAIN_UI_PREFAB,
EXTRARES_CONFIG,
V2_ATLAS_IMPORT,
V2_ATLAS_NATIVE,
MOCK_AVATAR,
]
assets = [asset_record(config, atlas, asset_id, resource) for asset_id, resource in V2_ASSETS.items()]
assets.append(
{
"id": "mock_avatar",
"bundle": "runtime_mock",
"resource": "openDataContext/render/avatar.png",
"sourcePath": str(MOCK_AVATAR),
"spriteFrame": None,
"pixelSize": image_info(MOCK_AVATAR),
"sourceNote": "Runtime mock avatar image from the extracted package. It fills the source headMask/head runtime mount point and does not define layout.",
}
)
source_files.extend(Path(item["sourcePath"]) for item in assets)
missing = [str(path) for path in source_files if not path.exists()]
node_label_by_name = {
"txtName": labels[94],
"txtLevel": labels[95],
"txtExp": labels[96],
"txtGold": labels[98],
"txtDiamond": labels[99],
}
mock_user = {
"nickname": "黄昏",
"level": 4,
"exp": 201,
"expMax": 600,
"gold": 58000,
"goldDisplay": "5.8万",
"diamond": 260,
"avatarAssetId": "mock_avatar",
}
nodes = [
{
"sourceIndex": 1,
"path": "main_ui_v2",
"name": "main_ui_v2",
"parentPath": "root/ui",
"localPosition": {"x": 0.0, "y": 0.0, "z": 0.0},
"localScale": {"x": 1.0, "y": 1.0, "z": 1.0},
"localEuler": {"x": 0.0, "y": 0.0, "z": 0.0},
"size": {"x": 720.0, "y": 1280.0},
"anchor": {"x": 0.5, "y": 0.5},
"active": True,
"widget": main_widget,
"sourceNote": "The compact prefab root serializes a cc.UITransform of 720 x 1280.",
},
node_from_element(
elements,
"main_ui_v2/HeadInfo",
size={"x": 200.0, "y": 200.0},
anchor={"x": 0.0, "y": 1.0},
widget=head_info_widget,
),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo"),
node_from_element(elements, "main_ui_v2/HeadInfo/btnExit", sprite_asset_id="btn_quit"),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/head_di", sprite_asset_id="img_head_di", sprite_type=1),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/img_head_di1", sprite_asset_id="img_head_di1"),
node_from_element(
elements,
"main_ui_v2/HeadInfo/SubHeadInfo/headMask",
size={"x": 71.0, "y": 71.0},
mask={"type": 1, "segments": 30, "fillColorUint32": 16777215},
),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/headMask/head", size={"x": 71.0, "y": 71.0}, sprite_asset_id="mock_avatar"),
node_from_element(
elements,
"main_ui_v2/HeadInfo/SubHeadInfo/node_head_frame",
source_note="The source node has UITransform 112 x 112 and a Sprite component without a serialized SpriteFrame; recover the avatar-frame runtime skin mapping before adding frame texture.",
),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/expBar", sprite_asset_id="img_level_bar", sprite_type=1, progress_bar=progress),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/expBar/Bar"),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/img_level", sprite_asset_id="img_level"),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/txtName", label=node_label_by_name["txtName"]),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/txtLevel", label=node_label_by_name["txtLevel"]),
node_from_element(elements, "main_ui_v2/HeadInfo/SubHeadInfo/txtExp", label=node_label_by_name["txtExp"]),
node_from_element(
elements,
"main_ui_v2/Source",
size=source_ui_transform["size"],
anchor=source_ui_transform["anchor"],
widget=source_widget,
source_note=source_ui_transform["engineDefaultEvidence"],
),
node_from_element(elements, "main_ui_v2/Source/root"),
node_from_element(elements, "main_ui_v2/Source/root/img_source_di", sprite_asset_id="img_source_di_v2", sprite_type=1),
node_from_element(elements, "main_ui_v2/Source/root/img_gold", sprite_asset_id="img_gold"),
node_from_element(elements, "main_ui_v2/Source/root/txtGold", label=node_label_by_name["txtGold"]),
node_from_element(elements, "main_ui_v2/Source/root/img_source_di1", sprite_asset_id="img_source_di_v2", sprite_type=1),
node_from_element(elements, "main_ui_v2/Source/root/img_diamond", sprite_asset_id="img_diamond"),
node_from_element(elements, "main_ui_v2/Source/root/txtDiamond", label=node_label_by_name["txtDiamond"]),
]
evidence = {
"metadata": {
"module": "stage1_top_hud",
"sourceFiles": [str(path) for path in source_files],
"sourcePrefab": str(MAIN_UI_PREFAB),
"bundle": "extraRes",
"assetPath": "gui/prefab/main_ui_v2",
"extractionRule": "Recover only the top HUD subset from main_ui_v2 source elements, compact Label/ProgressBar components, extraRes config path entries, and the 03f07fafe v2 SpriteFrame atlas. Do not infer layout from the reference screenshot.",
},
"customComponents": {
"headInfo": {
"classIndex": 35,
"uuid": prefab[3][35][0],
"fields": prefab[3][35][1],
"bindingReason": "HeadInfo custom component explicitly binds headImg, txtName, txtLevel, txtExp, expBar, btnQuit, btnHead, imgLevel, node_head_frame, node_month_card, and subNode.",
},
"mainUi": {
"classIndex": 39,
"uuid": prefab[3][39][0],
"fields": prefab[3][39][1],
"bindingReason": "Main UI custom component binds headComp and sourceComp; this pass recovers the child nodes consumed by those components.",
},
},
"nodes": nodes,
"assets": assets,
"mockUser": mock_user,
"solutionDetails": [
"node_head_frame serializes a Sprite component but no static SpriteFrame in the compact prefab. The runtime avatar-frame skin mapping is still unresolved, so this pass creates the source node without inventing a frame texture.",
"expBar has a source cc.ProgressBar with totalLength=135.5 and barSprite ref=158, but the bar sprite's separate SpriteFrame is not serialized in the recovered static subset. The mock script updates progress value; recover the exact foreground fill art from runtime source before adding it.",
"txtLevel and txtExp serialize enableOutline and outlineColor in compact cc.Label data, but no outline-width field is present in the recovered source subset. This pass preserves label layout, color, text, and mock values without inventing a Creator LabelOutline width.",
],
}
write_json(OUT_PATH, evidence)
report = {
"status": "pass" if not missing and len(labels) == 5 else "needs_solution",
"sourceEvidence": str(OUT_PATH),
"sourceFileCount": len(source_files),
"nodeCount": len(nodes),
"assetCount": len(assets),
"labelCount": len(labels),
"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())