first commit

This commit is contained in:
2026-08-01 14:00:17 +02:00
commit 130f8b4c1d
60 changed files with 9324 additions and 0 deletions
+631
View File
@@ -0,0 +1,631 @@
# -*- coding: utf-8 -*-
"""
check_duplicate_ids.py — RA3 Mod XML 重复 ID 检测工具
从 Data/Mod.xml 开始递归遍历所有 <Include type="all">,解析 DATA:/ART:/AUDIO:
前缀路径,收集所有顶层元素(AssetDeclaration 的直接子元素),并报告相同标签名
+ 相同 id 的重复冲突。
用法:
python tools/check_duplicate_ids.py --sdk-dir C:\Apps\RA3-MOD-SDK-X
python tools/check_duplicate_ids.py --sdk-dir C:\Apps\Ra3QuantumCompiler
python tools/check_duplicate_ids.py --sdk-dir C:\Apps\RA3-MOD-SDK-X --project-dir D:\Mods\CoronaMod\mods\mods\corona
假如路径里有空格,请用引号括起来。
"""
import argparse
import os
import re
import sys
import time
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
from typing import Optional, Dict, Tuple
# ── 命名空间常量 ────────────────────────────────────────────────────
NS_EALA = 'uri:ea.com:eala:asset'
NS_XAI = 'uri:ea.com:eala:asset:instance'
NS_XI = 'http://www.w3.org/2001/XInclude'
def strip_ns(tag: str) -> str:
"""去掉 XML 命名空间前缀,返回纯标签名。"""
if '}' in tag:
return tag.split('}', 1)[1]
return tag
def get_ns_uri(tag: str) -> str:
"""提取标签的命名空间 URI(无花括号)。"""
if '}' in tag:
return tag.split('}', 1)[0][1:]
return ''
def norm(p: Path) -> Path:
"""返回标准化的绝对路径。"""
return p.resolve()
g_cache: Dict[Tuple[str, Optional[Path]], Optional[Path]] = {}
def find_file(source: str, search_paths: dict, current_dir: Optional[Path]) -> Optional[Path]:
"""
解析 Include/@source 到实际文件路径:
- DATA:/ART:/AUDIO: 前缀 → 按优先级在各搜索路径中查找
- 无前缀 → 相对于当前文件所在目录
对于 ART: 路径,支持 2-letter prefix matching
如果源路径无目录分隔符且直接查找失败,提取文件名前 2 个小写字
母作为子目录再次尝试(如 JUAntiShip → ju/JUAntiShip)。
返回第一个匹配的文件,未找到返回 None。
"""
source = source.strip().replace('\\', '/')
# 前缀统一大写
for prefix in ("DATA:", "ART:", "AUDIO:"):
if source[:len(prefix)].upper() == prefix:
source = prefix + source[len(prefix):]
break
if (source, current_dir) in g_cache:
return g_cache[(source, current_dir)]
result = _find_no_cache(source, search_paths, current_dir)
g_cache[(source, current_dir)] = result
return result
def _find_no_cache(source: str, search_paths: dict, current_dir: Optional[Path]) -> Optional[Path]:
for prefix in ('DATA:', 'ART:', 'AUDIO:'):
if source.upper().startswith(prefix):
rel = source[len(prefix):].lstrip('/')
# 直接查找
result = _search_in_paths(rel, search_paths.get(prefix, []))
if result:
return result
# ART: 路径:如果无目录分隔符,尝试 2-letter prefix matching
if prefix == 'ART:' and '/' not in rel and '\\' not in rel:
first_two = rel[:2].lower()
prefixed_rel = f"{first_two}/{rel}"
result = _search_in_paths(prefixed_rel, search_paths.get(prefix, []))
if result:
return result
return None
# 无前缀 → 相对路径
if current_dir is not None:
candidate = norm(current_dir / source)
if candidate.is_file():
return candidate
return None
def _search_in_paths(rel_path: str, base_paths: list[Path]) -> Optional[Path]:
"""在多个基路径中依次搜索相对路径,返回第一个存在的文件。"""
for base in base_paths:
candidate = norm(base / rel_path)
if candidate.is_file():
return candidate
return None
# ══════════════════════════════════════════════════════════════════════
# xpointer 简易解析
# ══════════════════════════════════════════════════════════════════════
def parse_xpointer(xpointer_str: str) -> Optional[dict]:
"""
简易解析 xpointer 表达式,仅支持本项目使用的格式:
xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
返回 { 'ns_map': {'n': 'uri:...'}, 'element': 'ElementName' }
不支持复杂 xpath 时返回 None。
"""
if not xpointer_str:
return None
# 提取 xmlns 映射
ns_map = {}
for m in re.finditer(r'xmlns\((\w+)=([^\s)]+)\)', xpointer_str):
ns_map[m.group(1)] = m.group(2)
# 提取 xpointer(/prefix:ElementName/child::*)
m = re.search(r'xpointer\(/(\w+):(\w+)/child::\*\)', xpointer_str)
if m:
prefix = m.group(1)
elem_name = m.group(2)
return {'ns_map': ns_map, 'element': elem_name, 'prefix': prefix}
return None
def resolve_xpointer_target(root: ET.Element, xpointer_info: dict) -> list[ET.Element]:
"""
根据解析后的 xpointer 信息,从已解析的 XML 树中找到对应元素并返回其子元素。
"""
prefix = xpointer_info.get('prefix', '')
elem_name = xpointer_info.get('element', '')
ns_map = xpointer_info.get('ns_map', {})
ns_uri = ns_map.get(prefix, NS_EALA)
# 构造带命名空间的标签名
target_tag = f'{{{ns_uri}}}{elem_name}'
# 寻找匹配元素
for elem in root.iter():
if elem.tag == target_tag:
return list(elem)
return []
# ══════════════════════════════════════════════════════════════════════
# 核心检测器
# ══════════════════════════════════════════════════════════════════════
class DuplicateIdChecker:
"""
从 Mod.xml 出发递归遍历 Include 树,收集顶层元素,报告重复 ID 冲突。
"""
def __init__(self, sdk_dir: Path, project_dir: Path, cwd: Optional[Path] = None):
self.sdk_dir = norm(sdk_dir)
self.project_dir = norm(project_dir)
self._cwd = norm(cwd) if cwd else self.project_dir
# ── 自动推断搜索路径 ──
# 从 CLI 命令推导的结构:
# project_dir = D:\Mods\CoronaMod\mods\mods\corona
# project_dir.parent = D:\Mods\CoronaMod\mods\mods
# project_dir.parent.parent = D:\Mods\CoronaMod\mods
shared = self.project_dir.parent.parent # D:\Mods\CoronaMod\mods
mods = self.project_dir.parent # D:\Mods\CoronaMod\mods\mods
pj_data = self.project_dir / 'Data'
pj_art1 = self.project_dir / 'Art1'
pj_art = self.project_dir / 'Art'
pj_aud1 = self.project_dir / 'Audio1'
pj_aud = self.project_dir / 'Audio'
sdk_art = self.sdk_dir / 'Art'
sdk_aud = self.sdk_dir / 'Audio'
sdk_sage = self.sdk_dir / 'SageXml'
self._search_paths: dict[str, list[Path]] = {
'DATA:': [sdk_dir, pj_data, mods, sdk_sage],
'ART:': [sdk_dir, pj_art1, pj_art, mods, sdk_art],
'AUDIO:': [sdk_dir, pj_aud1, pj_aud, mods, sdk_aud],
}
# SageXml 路径:用于标记来自 SageXml 的元素
self._sagexml_dir = sdk_sage
# SDK Art 路径:用于标记来自 SDK Art 的元素
self._sdk_art_dir = sdk_art
# 状态
self._visited: set[str] = set() # 已访问过的文件(绝对路径)
self.manifest: list[tuple] = [] # (tag, id, rel_path, line)
self.dep_tree: list[tuple] = [] # (depth, ref_src, resolved_path)
self.not_found: list[tuple] = [] # (source, parent_file)
self.errors: list[str] = []
self._sagexml_files: set[str] = set() # SageXml 下的文件(用于冲突过滤)
# ── 公开入口 ──────────────────────────────────────────────────────
def run(self) -> int:
"""
执行完整检测流程。
返回: 0=正常, 1=发现冲突, 2=严重错误
"""
t_start = time.perf_counter()
mod_xml = self.project_dir / 'Data' / 'Mod.xml'
if not mod_xml.is_file():
print(f"❌ Mod.xml 未找到: {mod_xml}", file=sys.stderr)
return 2
print(f"🔍 起始文件: {mod_xml}")
print(f"📁 项目目录: {self.project_dir}")
print(f"📁 SDK 目录 : {self.sdk_dir}")
print(f"📁 工作目录 : {self._cwd}")
print()
for prefix in ('DATA:', 'ART:', 'AUDIO:'):
paths = self._search_paths.get(prefix, [])
markers = [f"{'' if p.is_dir() else ''}" for p in paths]
lines = [f" {i}. {p}{m}" for i, (p, m) in enumerate(zip(paths, markers), 1)]
print(f" {prefix} 搜索路径:")
for l in lines:
print(l)
print()
self._process_file(mod_xml, depth=0, reference='Mod.xml')
# ── 冲突检测 ──
# 仅在模组文件(非 SageXml)之间检测冲突。
# SageXml 中的同名元素是引擎基础,被模组覆盖是正常行为。
grouped: dict[tuple[str, str], list] = defaultdict(list)
for tag, eid, src, line in self.manifest:
# 跳过来自 SageXml 的条目
if src in self._sagexml_files:
continue
grouped[(tag, eid)].append((src, line))
conflicts = {k: v for k, v in grouped.items() if len(v) > 1}
# 统计 SageXml 覆盖信息(用于报告)
sagexml_overrides = 0
sagexml_only = set()
for tag, eid, src, line in self.manifest:
if src in self._sagexml_files:
sagexml_only.add((tag, eid))
if sagexml_only:
# 检查这些 SageXml 提供的 id 是否被模组覆盖
mod_ids = set()
for tag, eid, src, line in self.manifest:
if src not in self._sagexml_files:
mod_ids.add((tag, eid))
sagexml_overrides = len(sagexml_only & mod_ids)
# ── 输出 ──
self._print_dep_tree()
self._print_manifest()
self._print_conflicts(conflicts)
self._print_not_found()
self._print_errors()
if conflicts:
print(f"\n⚠️ 发现 {len(conflicts)} 组重复 ID(相同标签名 + 相同 id)")
else:
print("\n✅ 未发现模组内的重复 ID 冲突")
if sagexml_overrides:
print(f"📌 模组覆盖了 {sagexml_overrides} 个来自 SageXml 的 id(正常行为)")
t_elapsed = time.perf_counter() - t_start
if t_elapsed < 60:
print(f"⏱ 耗时: {t_elapsed:.2f}")
else:
minutes = int(t_elapsed // 60)
seconds = t_elapsed % 60
print(f"⏱ 耗时: {minutes}{seconds:.1f}")
print()
return 1 if conflicts else 0
# ── 文件处理 ──────────────────────────────────────────────────────
def _process_file(self, file_path: Path, depth: int, reference: str):
"""解析一个 XML 文件:收集顶层元素,递归处理 Includes。"""
resolved = norm(file_path)
key = str(resolved)
if key in self._visited:
return
self._visited.add(key)
# 记录依赖
try:
rel = resolved.relative_to(self.project_dir)
except ValueError:
rel = resolved
self.dep_tree.append((depth, reference, str(rel)))
# 标记是否来自 SageXml
is_sagexml = self._sagexml_dir in resolved.parents\
or str(resolved).startswith(str(self._sagexml_dir))
if is_sagexml:
self._sagexml_files.add(str(rel))
# 检测是否来自 SDK Art
is_sdk_art = self._sdk_art_dir in resolved.parents\
or str(resolved).startswith(str(self._sdk_art_dir))
if is_sdk_art:
# 检测文件大小是否小于等于 75 字节(空 XML)
try:
if resolved.stat().st_size <= 75:
return
except Exception:
pass
self._sagexml_files.add(str(rel)) # 也视为基础资源,避免冲突
root = self._parse(resolved)
if root is None:
return
local = strip_ns(root.tag)
if local != 'AssetDeclaration':
self.errors.append(
f" 跳过非 AssetDeclaration 根元素: {rel} (根={local})"
)
return
# 遍历 AssetDeclaration 的直接子元素
for child in root:
child_local = strip_ns(child.tag)
ns_uri = get_ns_uri(child.tag)
# ── <Includes> ──
if child_local == 'Includes':
self._process_includes(child, resolved, depth)
continue
# ── <xi:include> 在顶层 ──
if child_local == 'include' and ns_uri == NS_XI:
self._process_xi_include(child, resolved, depth)
continue
# ── <Tags> 跳过 ──
if child_local == 'Tags':
continue
# ── 顶层内容元素 ──
element_id = child.get('id')
if element_id:
line = getattr(child, 'sourceline', None)
self.manifest.append((child_local, element_id, str(rel), line))
def _process_includes(self, includes_elem, current_file: Path, depth: int):
"""处理 <Includes> 块中的 <Include> 元素。"""
for inc in includes_elem:
local = strip_ns(inc.tag)
if local != 'Include':
continue
if inc.get('type') != 'all':
continue
source = inc.get('source')
if not source:
continue
resolved = find_file(source, self._search_paths, current_file.parent)
if resolved and resolved.is_file():
self._process_file(resolved, depth + 1, reference=source)
else:
self.not_found.append((source, str(current_file)))
def _process_xi_include(self, xi_elem, current_file: Path, depth: int):
"""
处理位于 AssetDeclaration 顶层的 <xi:include>。
解析 xpointer(如有),读取目标文件并提取可能成为顶层元素的子元素。
"""
href = xi_elem.get('href')
if not href:
return
resolved = find_file(href, self._search_paths, current_file.parent)
if not resolved or not resolved.is_file():
self.not_found.append((f"xi:{href}", str(current_file)))
return
try:
rel = resolved.relative_to(self.project_dir)
except ValueError:
rel = resolved
# 避免重复处理
key = str(norm(resolved))
if key in self._visited:
return
self._visited.add(key)
self.dep_tree.append((depth, f"xi:{href}", str(rel)))
root = self._parse(resolved)
if root is None:
return
# 获取 xpointer
xp_str = xi_elem.get('xpointer', '')
xp_info = parse_xpointer(xpointer_str=xp_str)
if xp_info:
# xpointer 指向某个容器元素,取其子元素
target_children = resolve_xpointer_target(root, xp_info)
candidates = target_children
else:
# 没有 xpointer 或无法解析,取根元素的直接子元素
candidates = list(root)
# 将候选项作为顶层元素处理
for elem in candidates:
elem_local = strip_ns(elem.tag)
# 跳过非内容元素
if elem_local in ('Tags', 'Includes'):
continue
element_id = elem.get('id')
if element_id:
line = getattr(elem, 'sourceline', None)
self.manifest.append((elem_local, element_id, str(rel), line))
# ── 辅助方法 ──────────────────────────────────────────────────────
def _parse(self, file_path: Path) -> Optional[ET.Element]:
"""解析 XML 文件,返回根元素。
自动处理常见编码问题:当 XML 声明 encoding 与实际编码不匹配时
(例如声明 us-ascii 但文件实际为 UTF-8),尝试自动修正。
"""
# 第一次尝试:正常解析
try:
tree = ET.parse(file_path)
return tree.getroot()
except ET.ParseError as e:
error_msg = str(e)
# 如果是不规范的 token 错误,可能是编码声明与实际编码不符
if 'not well-formed (invalid token)' in error_msg:
try:
with open(file_path, 'rb') as f:
raw = f.read()
# 修复常见的错误编码声明:us-ascii → utf-8
raw = raw.replace(b'us-ascii', b'utf-8')
raw = raw.replace(b'US-ASCII', b'UTF-8')
raw = raw.replace(b'us-ASCII', b'utf-8')
tree = ET.fromstring(raw)
return tree
except Exception:
pass
self.errors.append(f" XML 解析错误: {file_path}{e}")
return None
except Exception as e:
self.errors.append(f" 读取失败: {file_path}{e}")
return None
# ── 输出方法 ──────────────────────────────────────────────────────
def _print_dep_tree(self):
"""打印 Include 依赖树。"""
print("" * 60)
print("📂 Include 依赖树")
print("" * 60)
# 只打印前 100 行以免刷屏
shown = 0
for depth, ref, resolved in self.dep_tree:
indent = " " * depth
prefix = "├─ " if depth > 0 else "└─ "
src = ref if depth > 0 else resolved
print(f"{indent}{prefix}{src}")
shown += 1
if shown >= 200:
remaining = len(self.dep_tree) - shown
if remaining > 0:
print(f" ... 还有 {remaining} 个文件未显示")
break
print(f" (共处理 {len(self.dep_tree)} 个文件)")
print()
def _print_manifest(self):
"""打印所有收集到的顶层元素清单。"""
print("" * 60)
print("📋 顶层元素清单(按来源文件分组)")
print("" * 60)
# 按来源文件分组
by_file: dict[str, list] = defaultdict(list)
for tag, eid, src, line in self.manifest:
by_file[src].append((tag, eid, line))
for file_idx, (src, elems) in enumerate(sorted(by_file.items()), 1):
if file_idx > 50:
remaining = len(by_file) - 50
print(f"\n ... 还有 {remaining} 个文件的元素未显示")
break
print(f"\n 📄 {src} ({len(elems)} 个元素):")
for tag, eid, line in sorted(elems, key=lambda x: (x[0], x[1])):
loc = f" (行 {line})" if line else ""
print(f" • <{tag} id=\"{eid}\">{loc}")
total = len(self.manifest)
print(f"\n{total} 个顶层元素")
print()
def _print_conflicts(self, conflicts: dict):
"""打印重复 ID 冲突报告。"""
print("" * 60)
print("⚠️ 重复 ID 冲突报告")
print("" * 60)
if not conflicts:
print(" (无冲突)")
return
# 按元素类型分组显示
by_type: dict[str, list] = defaultdict(list)
for (tag, eid), locations in conflicts.items():
by_type[tag].append((eid, locations))
total_conflicts = len(conflicts)
total_duplicates = sum(len(v) for v in conflicts.values())
for tag, items in sorted(by_type.items()):
print(f"\n ── <{tag}> ({len(items)} 个重复 id) ──")
for eid, locations in sorted(items):
print(f"\n id=\"{eid}\" — 出现 {len(locations)} 次:")
for src, line in locations:
loc = f" (行 {line})" if line else ""
print(f"{src}{loc}")
print(f"\n 📊 总计: {total_conflicts} 组冲突,涉及 {total_duplicates} 个引用")
print()
def _print_not_found(self):
"""打印找不到的文件列表。"""
if not self.not_found:
return
print("" * 60)
print("⚠️ 未找到的文件")
print("" * 60)
shown = 0
for src, parent in self.not_found:
if shown >= 30:
remaining = len(self.not_found) - shown
print(f" ... 还有 {remaining} 个未显示")
break
print(f"{src} (引用自 {parent})")
shown += 1
print(f"{len(self.not_found)} 个文件未找到")
print()
def _print_errors(self):
"""打印解析错误。"""
if not self.errors:
return
print("" * 60)
print("❌ 解析错误")
print("" * 60)
for err in self.errors:
print(err)
print()
# ══════════════════════════════════════════════════════════════════════
# 命令行入口
# ══════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(
description="RA3 Quantum Compiler XML 重复 ID 检测工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
%(prog)s --sdk-dir C:\\Apps\\Ra3QuantumCompiler
%(prog)s --sdk-dir C:\\Apps\\Ra3QuantumCompiler --project-dir D:\\Mods\\CoronaMod\\mods\\mods\\corona
%(prog)s --sdk-dir C:\\Apps\\Ra3QuantumCompiler --cwd D:\\Mods\\CoronaMod\\mods\\mods\\corona
路径自动推断规则:
DATA: 搜索路径 = {sdk}{project}/Data → {mods}{sdk}/SageXml
ART: 搜索路径 = {sdk}{project}/Art1 → {project}/Art → {mods}
AUDIO:搜索路径 = {sdk}{project}/Audio1 → {project}/Audio → {mods}
其中 mods = {project}/..
"""
)
parser.add_argument(
'--sdk-dir',
default=None,
help='RA3 Mod SDK 目录',
)
parser.add_argument(
'--project-dir',
default=None,
help='项目根目录(默认: 自动检测,从当前目录向上查找含 Data/Mod.xml 的目录)',
)
parser.add_argument(
'--cwd',
default=None,
help='工作目录(默认等于 --project-dir',
)
args = parser.parse_args()
# ── 确定项目目录 ──
project_dir = None
if args.project_dir:
project_dir = Path(args.project_dir)
else:
# 自动检测:从当前目录向上找 Data/Mod.xml
cur = Path.cwd()
for p in [cur] + list(cur.parents):
if (p / 'Data' / 'Mod.xml').is_file():
project_dir = p
break
if project_dir is None:
print("❌ 无法自动检测项目目录(未找到 Data/Mod.xml", file=sys.stderr)
print(" 请使用 --project-dir 参数指定", file=sys.stderr)
sys.exit(2)
if args.sdk_dir is None:
print("❌ 未指定 SDK 目录", file=sys.stderr)
sys.exit(2)
sdk_dir = Path(args.sdk_dir)
cwd = Path(args.cwd) if args.cwd else project_dir
# ── 检查基本路径有效性 ──
if not sdk_dir.is_dir():
print(f"❌ SDK 目录不存在: {sdk_dir}", file=sys.stderr)
sys.exit(2)
if not project_dir.is_dir():
print(f"❌ 项目目录不存在: {project_dir}", file=sys.stderr)
sys.exit(2)
checker = DuplicateIdChecker(sdk_dir=sdk_dir, project_dir=project_dir, cwd=cwd)
sys.exit(checker.run())
if __name__ == '__main__':
main()