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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
out/
dist/
*.vsix
OpenSAGE/
+17
View File
@@ -0,0 +1,17 @@
.vscode/**
.git/**
src/**
test/**
tools/**
out/**
node_modules/**
prompts
check_duplicate_ids.py
debug.log
docs/**
OpenSAGE/**
test/**
**/*.map
tsconfig.json
esbuild.mjs
package-lock.json
+79
View File
@@ -0,0 +1,79 @@
# RA3 Mod XMLVS Code 扩展)
面向《命令与征服:红色警戒 3》Mod XMLSAGE / BinaryAssetBuilder 格式)的 VS Code 工具扩展。
## 功能
- **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom``xai:joinAction`、结构标签)。
- **自动补全**
- 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject``WeaponTemplate` 等 99+ 类型。
- 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `xai:joinAction``xmlns:xai`
- 属性值:
- 引用型属性(如 `CommandSet``Weapon`)按 `xas:refType` 补全对应类型的资产 ID(**同名 ID 只补全匹配类型**);
- `inheritFrom` 补全可继承的资产 ID
- 枚举(如 `Include type`)、布尔值、`$DEFINE` 常量;
- `<Include source>` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。
- **悬停提示**:元素/属性显示 XSD 文档、类型、必填/默认值;引用值显示定义位置;`$DEFINE` 显示值与定义位置。
- **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom`)跳转到定义(严格按引用类型过滤);`Ctrl+点击` Include 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE`
- **错误检查**:XML 格式错误、未知元素/属性、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include 找不到、`$DEFINE` 未定义。
- **manifest 支持**`<Include type="reference">` 指向的 `static/global/audio.manifest`SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。
## 使用
1. 用 VS Code 打开 RA3 Mod 项目文件夹(含 `Data/Mod.xml``mod.babproj`)。
2. 插件自动激活并开始后台索引(状态栏显示资产数量)。
3. 编辑任意 `*.xml` 即可获得补全、跳转与诊断。
### 设置(`settings.json`
| 设置 | 默认值 | 说明 |
|---|---|---|
| `ra3modxml.sdkPath` | `C:\Apps\RA3-MODSDK-X` | Mod SDK 根目录 |
| `ra3modxml.indexSageXml` | `true` | 是否索引 SDK 的 `SageXml` 原版源码 |
| `ra3modxml.reportUnresolvedReferences` | `warning` | 未解析引用诊断级别(`warning`/`information`/`none` |
| `ra3modxml.diagnoseUnknownElements` | `true` | 是否报告未知元素/属性(自定义 XSD 项目可关闭) |
| `ra3modxml.additionalDataSearchPaths` | `[]` | 追加的 `DATA:` 搜索目录 |
### 命令
- `RA3 Mod XML: Re-index workspace`:手动重建索引。
- `RA3 Mod XML: Show index report`:查看索引统计。
## 开发
```powershell
npm install
npm run generate-model # 从 SDK XSD 重新生成 src/model/schema-model.json
npm test # 单元测试(tsc + node --test
npm run build # esbuild 打包到 dist/
npm run package # 生成可安装的 .vsix
```
测试夹具:`test/fixtures/minimod`(含 include、重复 ID、同名不同类型 ID、manifest 回退等场景)。
## 架构
```
src/
extension.ts 激活入口与 provider 注册
workspace.ts 项目检测、索引生命周期、状态栏
language/xmlParser.ts 带源码偏移的轻量 XML 解析器
language/context.ts 补全上下文分析
model/schemaModel.ts XSD 模型运行时(由 tools 生成 JSON 驱动)
indexer/
includeResolver.ts Include 路径解析(纯 TS,移植 check_duplicate_ids.py
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs
refs.ts 引用目标解析(按引用类型过滤)
indexer.ts 工作区索引器(后台、缓存、增量重建)
features/ completion / hover / navigation / diagnostics
syntaxes/ TextMate 注入语法
tools/ XSD → 模型、AssetType 枚举提取
```
解析/索引核心不依赖 VS Code API,可被其他工具复用(见 `docs/plan.md` 的远期目标:搜索与索引复用)。
## 参考
- 领域说明与需求:`docs/requirements.md`
- 调研与设计决策:`docs/plan.md`
- Manifest 格式参考:OpenSAGE `src/OpenSage.Game/Data/StreamFS/ManifestFile.cs`(本仓库 `OpenSAGE/` 子目录,commit `d45d361`
+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()
+1
View File
@@ -0,0 +1 @@
[0801/032923.150:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 拒绝访问。 (0x5)
+242
View File
@@ -0,0 +1,242 @@
# 问题分析:AttachTest 高亮异常与误报诊断
> 日期:2026-08-01。基于在 AttachTest `Data/Mod.xml` 上复现的问题。
> 本文件只做分析,**代码尚未修改**,修复方案见文末,待审阅后实施。
## 一、现象
1. **语法高亮异常**
- `<?xml version="1.0" encoding="UTF-8"?>` 显示为纯文本颜色;
- `<Includes` 之后的 `>` 无高亮;
- `<Include type="reference" ...>``Include` 之后的内容全部无高亮;
- 整体表现为"只有少量标签名被着色,其余按纯文本处理"。
2. **误报诊断**
- `<Include type="reference">``type` 值报 `Unresolved reference "reference"`
- `<Include source="DATA:Static.xml">``source` 值报 `Unresolved reference`
- 同一处 hover 显示解析结果为 `C:\Apps\RA3-MODSDK-X\SageXml\Static.xml`,但实际应按编译参数解析到 SDK 根目录 `C:\Apps\RA3-MODSDK-X\Static.xml`
## 二、实测证据
用当前构建代码对 AttachTest 索引后验证:
```
[resolve] DATA:Static.xml -> C:\Apps\RA3-MODSDK-X\Static.xml ← 解析器结果正确
[candidate] 精确匹配 -> C:\Apps\RA3-MODSDK-X\SageXml\Static.xml ← hover 用的是这条
[model] Include@type refType: null enum: [reference, instance, all]
[model] Include@source refType: null type: anyURI
[diag] Include@type='reference' targets: 0
```
三个问题均稳定复现。
## 三、根因分析
### 1. 高亮异常:grammar 注册方式覆盖了内置 XML 语法
`package.json` 中的 grammar 贡献写法:
```json
{
"language": "xml",
"scopeName": "source.ra3modxml",
"injectTo": ["source.xml"]
}
```
同时填写 `language``injectTo` 时,VS Code 会把该 grammar 注册为 **`xml` 语言的主 grammar**(替换内置 XML 语法),而不仅是注入。结果是:
- 内置 `source.xml` 的完整 token 化规则不再生效;
- 我的语法只有几个领域关键词模式,没有 XML 声明、标签括号、属性名/值的规则;
- 于是 `<?xml ...?>``>`、属性区全部退化为纯文本,只有 `$DEFINE``inheritFrom`、结构标签名等被我的模式着色——与现象完全吻合。
正确的做法是注入式 grammar 不声明 `language`(VS Code 官方注入语法示例即省略该字段)。
### 2. 误报 `Unresolved reference`:诊断把所有属性值都当成"引用"检查
诊断代码在 `checkValueReferences` 中先调用 `resolveReferenceTargets(...)`,并以其返回空数组作为"未解析"依据。而 `resolveReferenceTargets` 对**非引用属性**(如 `Include@type` 枚举、`Include@source``anyURI`)也返回空数组——两者无法区分。
这是早期实现回归:最初代码有 `isRef`(检查 `refType``inheritFrom`)守卫,重构为共享函数后丢失了该守卫。
同理,hover 对非引用属性值也会走到"未找到匹配定义"分支,显示误导性文案。
### 3. `DATA:Static.xml` hover 路径错误:候选表优先于解析器,且候选表大小写不统一
- 解析器 `resolveSource` 按搜索路径正确解析到 SDK 根 `Static.xml`
- 但 hover/定义跳转/文档链接都是**先查 `idx.sourceCandidates` 精确匹配**
- 候选表里同一 source 字符串 `DATA:Static.xml` 出现两条:一条来自 `SageXml\Static.xml`(扫描更早、排在前面),一条来自 SDK 根目录浅扫描;
- `find` 精确匹配返回第一条 → 显示 SageXml 路径,而按 BAB 顺序应先命中 SDK 根目录。
修复方向相应调整为:**候选表只服务补全**;hover/跳转/链接一律先走 `resolveSource`(按 BAB 顺序校验文件存在)。候选表本身也按 source 去重并让 SDK 根目录条目排前。
另外,当前搜索路径来自 `check_duplicate_ids.py` 的简版(`[SDK根, 项目Data, Mods, SDK/SageXml]`),与 BAB 编译参数不完全一致,缺少 `modGranParent``SDK\Mods` 两个条目。`defaultscript.cs``getIncludePaths()` 实际参数为:
```
/data: ".;{modGranParent};{0}\Data;.\Mods;{1};.\SageXml"
/art: ".;{modGranParent};{0}\Art1;{0}\Art;.\Mods;{1};.\Art"
/audio: ".;{modGranParent};{0}\Audio1;{0}\Audio;.\Mods;{1};.\Audio"
其中 {0}=ModPath, {1}=modParentPath, {2}=modGranParent, "." = SDK 根目录(编译时 cd 到 SDK)
```
展开后(以 AttachTest 为例):
| 前缀 | 搜索路径(BAB 顺序) |
|---|---|
| DATA | SDK根 → modGranParent → Mod\Data → SDK\Mods → modParentPath → SDK\SageXml |
| ART | SDK根 → modGranParent → Mod\Art1 → Mod\Art → SDK\Mods → modParentPath → SDK\Art |
| AUDIO | SDK根 → modGranParent → Mod\Audio1 → Mod\Audio → SDK\Mods → modParentPath → SDK\Audio |
`DATA:Static.xml` 在 BAB 顺序下首先命中 SDK 根的占位文件(与用户预期一致)。
## 四、修复方案
### 4.1 语法高亮(P0
- `package.json``grammars` 条目**移除 `"language": "xml"`**,只保留 `scopeName``path``injectTo`,使其成为纯粹的注入语法,叠加在内置 XML 语法之上。
- 精简 `syntaxes/ra3modxml.tmLanguage.json`:保留不冲突的模式(`$DEFINE` 常量、`inheritFrom``xai:joinAction`、TODO 注释、结构标签名);删除对 `Replace`/`Remove` 的裸词匹配(避免误染普通内容)。
- 验证方式:扩展开发宿主中打开 `Data/Mod.xml`,确认 XML 声明、标签、属性恢复完整高亮且领域关键词仍有专属颜色。
### 4.2 诊断与 hover 的引用判断(P0)
- 恢复"是否引用属性"守卫:仅当属性为 `inheritFrom` 或模型中有 `refType` 时才进入未解析引用检查;`Include@type``Include@source` 等不再误报。
- hover 同步加守卫:非引用属性值不再显示"未找到匹配定义"文案;`Include@source` 走专门的"Include 源文件"分支。
### 4.3 Include 源解析(P0
- hover / 定义跳转 / 文档链接统一改为**优先调用 `resolveSource`**(按 BAB 顺序、校验文件存在),候选表只作为兜底与补全来源。
- `buildSearchPaths` 对齐 BAB 编译参数:DATA/ART/AUDIO 各补上 `modGranParent``SDK\Mods` 两个搜索条目,顺序与 `defaultscript.cs` 一致。
- 候选表去重改为大小写不敏感(按 `source.toLowerCase()` 去重),避免同一文件因大小写不同出现两条候选。
### 4.4 测试补充
- 新增/更新单测:
- 非引用属性(`Include@type``Include@source`)不会产生 unresolved-reference 目标;
- `DATA:Static.xml` 按 BAB 顺序解析到 SDK 根目录而非 SageXml
- `buildSearchPaths` 展开结果与 BAB 参数一致(构造 SDK 内/外两种项目布局断言顺序)。
- 回归验证:AttachTest / GenEvoTest / Corona 各索引一次,确认 0 误报、补全/跳转正常。
### 4.5 不在本次范围
- P1 搜索与索引复用设计;
- manifest 深度解析(资产类型不匹配提示的进一步细化)。
## 五、风险与影响
- grammar 改动只影响高亮层,不影响索引与诊断逻辑;
- 搜索路径顺序变化可能影响个别 include 的解析结果(更接近 BAB 真实行为),需在三个真实项目上回归;
- 候选表去重策略变化理论上减少补全条目重复,不影响条目正确性。
## 六、实施结果(2026-08-01
已按上述方案完成修复并验证:
1. **grammar 改为纯注入**`package.json``grammars` 条目移除 `"language": "xml"`,内置 XML 语法恢复为主语法,领域关键词以注入方式叠加。
2. **引用属性守卫恢复**:新增 `isReferenceAttribute()` 纯函数,诊断与 hover 仅对 `inheritFrom` / 带 `refType` 的属性做未解析引用检查;`Include@type``Include@source` 不再误报。
3. **Include 源解析优先 `resolveSource`**:hover / 定义跳转 / 文档链接统一先按 BAB 顺序解析;候选表仅作补全来源并按 source 去重、SDK 根目录条目排前。
4. **搜索路径对齐 BAB 参数**`buildSearchPaths` 的 DATA/ART/AUDIO 各补入 `modGranParent``SDK\Mods`,顺序与 `defaultscript.cs``/data /art /audio` 一致。
实测(AttachTestC: 盘):
```
DATA:Static.xml resolves to: C:\Apps\RA3-MODSDK-X\Static.xml (OK,不再指向 SageXml)
DATA:static.xml candidates: 1 -> C:\Apps\RA3-MODSDK-X\Static.xml (去重生效)
isReferenceAttribute(Include,type) = false
isReferenceAttribute(Include,source) = false
CommandSet 引用仍能解析到 LogicCommandSet 定义(无回归)
```
单元测试 24/24 通过(新增:BAB 搜索路径顺序、SDK 根优先于 SageXml、`isReferenceAttribute` 判定)。
> 注:本环境当前无法访问 `D:\Mods\CoronaMod`D: 盘不可见),GenEvoTest / Corona 的实机回归待 D: 盘可用后补跑;相关逻辑已被 AttachTest 与单元测试覆盖。
---
## 七、问题分析(第二轮,2026-08-01)
用户在 AttachTest `Guardian Tank\GameObject.xml` 上报了三个新问题,均已修复并验证。
### 问题 A`Side="Allies"` 误报"未被定义"
**现象**`Side="Allies"` 报 unresolved reference,但 `global.manifest` 中存在 `PlayerTemplate:Allies`
**根因**`Side` 属性确实是引用(`PlayerTemplateWeakRef``PlayerTemplate`),但 manifest 解析器依赖 OpenSAGE `AssetType.cs` 的 TypeId 哈希表,而该表**不完整**(实测 `Global.manifest` 11268 个资产中有 2707 个哈希未知,`PlayerTemplate` 即缺失)。未知类型被记成 `#哈希`,无法与 `refType=PlayerTemplate` 匹配。
**修复**manifest 资产名本身是 `类型名:ID` 格式(如 `PlayerTemplate:Allies`),新增 `deriveAssetType()`——哈希已知时用哈希;未知时从名称前缀推导类型(资产 ID 不允许含冒号,切分无歧义)。修复后 `Side="Allies"` 正确解析到 `PlayerTemplate@Global.manifest`
### 问题 B`<Weapon>` 报没有 `Ordering` 属性
**现象**`WeaponSetUpdate → WeaponSlotTurret → Weapon` 链中 `<Weapon Ordering=...>` 报未知属性;XSD 里 `WeaponSlot_WeaponData` 确实有 `Ordering`
**根因**:模型用"元素名→类型"的**全局单映射**,同名元素(如 `<Weapon>` 出现在武器槽、慢速死亡、引用等许多上下文)以"先到先得"注册。`Weapon` 被注册成 `WeaponRef`,因此按全局映射查不到 `Ordering`。这是典型的**上下文相关类型**问题。
**修复**:新增上下文感知类型解析:
- `childTypeOf(父类型, 子元素名)` / `elementTypeIn(父元素, 子元素名)` / `attributesOfType(类型)`
- `resolveElementType(元素)` 沿解析树向上逐层用父类型的子元素声明解析真实类型,失败时回退全局映射。
修复后 `Weapon`(在 `WeaponSlotTurret` 下)解析为 `WeaponSlot_WeaponData``Ordering` 合法;且 `Ordering` 本身不是引用,不再被误判。
### 问题 C:嵌套 `xi:include`(第 248 行)未处理
**现象**`<xi:include href="DATA:Includes/HeadlightDraw2.xml" xpointer="...">` 位于元素内部(非 `AssetDeclaration` 直接子级),原实现只处理根级 `xi:include`,嵌套的一律静默忽略。
**根因**:索引器 `walk()` 只遍历根的直接子元素处理 `xi:include`
**修复**:解析后遍历文档全部元素,对**任意层级**的 `xi:include`:解析 `href`(按 BAB 搜索路径);目标缺失时产生 `include-not-found` 诊断(不再静默);目标存在时记录并 walk 目标文件,使其内容/资产进入索引。`href` 的文档链接(Ctrl+点击)此前已可用。
### 举一反三的测试
- `test/typeContext.test.mjs`:用真实结构(GameObject → BehaviorModules → WeaponSetUpdate → WeaponSlotTurret → Weapon)验证上下文类型解析;`childTypeOf` 原语断言。
- `test/manifestTypes.test.mjs``deriveAssetType` 前缀推导(含无冒号/前导冒号边界);模拟 `PlayerTemplate:Allies``Side` 引用解析成功;`isReferenceAttributeOfType` 判定。
- `test/indexer.test.mjs`:新增嵌套 `xi:include` 断言——目标文件被索引、目标资产可用、缺失目标产生诊断。
- AttachTest 实机验证:`Side="Allies"``PlayerTemplate@Global.manifest``Weapon` 类型 → `WeaponSlot_WeaponData`(含 `Ordering`);`HeadlightDraw2.xml` 进入索引、无相关诊断。
单元测试 28/28 通过,`.vsix` 已重新打包。
---
## 八、问题分析(第三轮,2026-08-01)
用户在 AttachTest `Guardian Tank\GameObject.xml` 上报了三个问题,均已修复并验证。
### 问题 A`Locomotor="..."` 误报 "has no definition of type true"
**现象**`LocomotorSet/@Locomotor` 引用报错,且类型显示为 **`true`**。
**根因**:模型生成器把简单类型的 `xas:isRef="true"` 当成了 `refType` 值。`Locomotor` 的类型是 `AssetReference`(只有 `xas:isRef="true"`、没有 `refType`),于是 refType 被写成字符串 `"true"`,导致任何类型匹配都失败。这是生成器 `refType` 取值逻辑的 bug。
**修复**
- 生成器只从 `xas:refType` 取 refType`xas:isRef` 只作为布尔标记 `isRef`(属性条目新增该字段);
- `isReferenceAttributeOfType``refType``isRef` 都视为引用;
- `resolveReferenceTargetsForType` 对**无类型引用**isRef 且无 refType)不做类型过滤,直接匹配同名 id——正是 `Locomotor` 这类"引用任意已声明资产"的语义。
修复后 `Locomotor="AlliedAntiVehicleVehicleTech1Locomotor"` 正确解析到 mod 的 `LocomotorTemplate`(以及 static.manifest 中的同名原版资产)。
### 问题 B`Name="AUAntiVehicleVehicleTech1_SKN"` 误报 BaseRenderAssetType 未定义
**现象**W3D 模型名引用报"no definition of type BaseRenderAssetType",但资产确实存在于 static.manifest。
**根因**:两个叠加原因:
1. manifest 名格式是 `类型:子类型:文件名`(如 `W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN`),旧的 id 提取只切掉第一个前缀,得到 `W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN`,无法匹配 XML 里的 `AUAntiVehicleVehicleTech1_SKN`
2. manifest 哈希表里的类型名是 `W3dContainer`(小写 d),而 XSD 类型名是 `W3DContainer`,大小写不一致导致继承链匹配失败。
**修复**
- `deriveAssetId` 改为取**最后一个冒号段**(资产 ID 不允许含冒号),兼容 `PlayerTemplate:Allies``W3dContainer:W3DContainer:...` 两种格式;
- `canonicalTypeName` 大小写不敏感规范化,`typeChain` / `isAssignableTo` / 属性查询统一走规范化;manifest 资产入库时类型名也规范化。
类型匹配严格遵循 XSD 继承链:`W3DContainer` / `W3DMesh``BaseRenderAssetType` ✓;`W3DHierarchy``BaseAssetType`**不是** BaseRenderAssetType)✗。实测 `_SKN` 只解析出 `W3DContainer@Static.manifest` 一个候选,碰撞盒(id 带 `.OBBOX` 后缀)与层级自然排除。
### 问题 C:跳转候选"只能打开文件、不能精确定位"
**现象**`Template="AlliedAntiVehicleVehicleTech1Cannon"` 有两个候选——mod 定义和 `SageXml\globaldata\weapon.xml`(由 manifest 的 `manifestSource` 映射而来)。后者只打开文件、不定位。
**修复**
- 所有 XML 定义的跳转改为**精确范围**:用 `id` 属性值的起止偏移构造 Range(找不到时回退到元素起始标签),mod 与 SageXml 一致;
- manifest 定义映射到源码文件时,同样在源码文件内查找同名 id 并精确定位(实测定位到 `weapon.xml:2357`);
- 新增配置 `ra3modxml.definitionMode``all`(默认,mod + 原版全部列出,mod 优先)或 `project-only`(项目内已定义时只跳 mod 定义)。
### 举一反三的测试(31/31 通过)
- `test/manifestTypes.test.mjs``deriveAssetId` 最后一段提取(含 W3D/纹理双前缀、无冒号边界);大小写规范化后的 `isAssignableTo``W3dContainer``BaseRenderAssetType` 为真、`W3dHierarchy` 为假)。
- `test/refs.test.mjs`:无类型引用(`LocomotorSet/@Locomotor`)解析到任意声明类型的同名 id;非引用属性仍返回空。
- AttachTest 实机:三个原始场景全部按预期(Locomotor 命中 LocomotorTemplate、_SKN 命中 W3DContainer、cannon 双候选均可精确定位)。
`.vsix` 已重新打包(13:33)。D 盘恢复后照旧可补跑 GenEvoTest / Corona 回归。
+144
View File
@@ -0,0 +1,144 @@
# 调研结论与实施计划(已按最新代码同步更新)
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-01)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、无类型引用、精确跳转范围、嵌套 `xi:include`、注入式语法高亮等。
## 一、调研结论(带证据)
### 1. Include 解析规则(证据:`check_duplicate_ids.py` + `defaultscript.cs`
`Data/Mod.xml` 递归处理 `<Include type="all">`(以及任意层级的 `xi:include`)。路径解析按 `defaultscript.cs` `getIncludePaths()` 的编译参数(`/data /art /audio``.` 为 SDK 根目录):
- `DATA:`SDK 根 → modGranParent → 项目 `Data``SDK\Mods` → modParentPath → `SDK\SageXml`
- `ART:`SDK 根 → modGranParent → 项目 `Art1` → 项目 `Art``SDK\Mods` → modParentPath → `SDK\Art`
- `AUDIO:`SDK 根 → modGranParent → 项目 `Audio1` → 项目 `Audio``SDK\Mods` → modParentPath → `SDK\Audio`
其中 modParentPath = 项目目录的父目录,modGranParent = modParentPath 的父目录(如 AttachTest 位于 SDK 内时二者都收敛到 SDK 目录)。无前缀路径相对于当前文件目录解析;`ART:` 支持 2 字母前缀匹配。`defaultscript.cs` 第 4 步“建立全局数据”按 `additionalmaps\mapmetadata_*.xml` 逐个编译;第 5 步“建立基础数据”编译 `Data/Mod.xml`
### 2. 文件规模(证据:实测统计)
- Corona `Data`**7540 个 XML / 38.1 MB**
- SDK `SageXml`**5976 个 XML / 21.4 MB**
- 合计约 1.3 万个文件、60 MB → 索引必须后台化、缓存解析结果、按需惰性加载。
### 3. XSD 结构(证据:`Schemas/xsd` 实测)
-**821 个 XSD / 1.5 MB**,入口 `CnC3Types.xsd`
- 根元素 `AssetDeclaration``Tags` / `Includes` / `Defines` + **295 个顶层资产元素**(含内联声明)。
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(如 `BaseInheritableAsset` 提供 `inheritFrom`)。
- `Includes/Ref.xsd` 定义了大量带 `xas:refType="<资产类型>"` 的引用类型(如 `CommandSet` 引用 `LogicCommandSet`)→ 补全/导航按引用类型过滤的依据。
- `XmlEdit:Default` 提供默认值;`xs:enumeration` 提供枚举值;`xas:isRef="true"``refType` 时为**无类型引用**(匹配任意已声明资产)。
- **同名元素在不同父节点下类型不同**(如 `<Weapon>` 在武器槽下是 `WeaponSlot_WeaponData`,在别处可能是 `WeaponRef`)→ 需要上下文感知解析。
### 4. 领域特有约定
- `$NAME` / `=$NAME``<Defines>` 中定义的常量引用(Corona `GlobalData/GlobalDefines.xml` 实测),属性值补全与 hover 支持。
- `xai:joinAction` 实测取值 `Replace``Remove`
- 原版数据双来源:SDK `SageXml` 提供 XML 源码;`reference` 指向的编译 manifest 提供完整资产表(含美术素材)。
### 5. 网络与工具链(证据:本机实测)
- Node v24.16.0、npm 11.13.0、VS Code 1.130 可用;无全局 `vsce`、Python 不可用。
- npm 注册表可用(需提升权限安装依赖);esbuild 原生二进制在沙箱内受限,构建需提升权限。
### 6. manifest 二进制格式(证据:工作区 `OpenSAGE/` 源码)
用户已克隆 OpenSAGE 并切到 `d45d361``src/OpenSage.Game/Data/StreamFS/ManifestFile.cs` 给出完整格式:
- 头部:首 4 字节为 0 时版本固定为 7;否则 `IsBigEndian`(1B) + `IsLinked`(1B) + `Version`(u165/6) + 10 个 u32 缓冲区/计数信息。
- 资产条目(版本 ≥ 6 时每条 48B):`TypeId`/`InstanceId`/`TypeHash`/`InstanceHash`/`AssetReferenceOffset`/`AssetReferenceCount`/`NameOffset`/`SourceFileNameOffset`/`InstanceDataSize`/`RelocationDataSize`/`ImportsDataSize`
- 之后依次是:资产引用缓冲区、被引用 manifest 名缓冲区、资产名字符串缓冲区、源文件名字符串缓冲区。
- `TypeId` 为哈希;OpenSAGE `AssetType.cs` 枚举提供部分哈希→类型名映射(**不完整**:`Global.manifest` 11268 个资产中 2707 个哈希未知,如 `PlayerTemplate`)。
- **实测发现**manifest 资产名是 `类型名:ID` 格式,美术资产还带子类型段(`W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN`)。类型可从第一个冒号段推导,可引用 ID 取最后一个冒号段。
**结论**manifest 解析为 P0 能力,用于 reference include 的资产补全/悬停/导航/诊断。
## 二、技术方案
### 选型
- **TypeScript + VS Code 扩展 API(非 LSP**:补全 / hover / 跳转 / 诊断 / 大纲均用原生 provider,无需语言服务器进程。
- **核心与编辑器解耦**`language/``model/``indexer/` 为纯 TS 模块(不 import `vscode`),可单测与复用(呼应 P1 需求 7)。
- **esbuild 打包**,产物 `dist/extension.js`
- **XSD → JSON 模型**:开发期工具 `tools/xsd-to-model.mjs` 把 821 个 XSD 解析成 `schema-model.json`(元素树、属性、文档、枚举、引用类型映射),随插件发布;运行时不再解析 XSD。
- **运行时 XML 解析**:自研带源码偏移的轻量解析器 `language/xmlParser.ts`(标签/属性/值均记录起止偏移,容错解析以支持输入中的补全与诊断)。`fast-xml-parser` 仅用于开发期 XSD 生成。
- **AssetType 哈希表**`tools/extract-asset-types.mjs` 从 OpenSAGE `AssetType.cs` 提取 `asset-types.json`;哈希未知时以 manifest 名称前缀推导类型。
### 架构
```
src/
extension.ts 激活入口(provider 注册、索引调度、诊断调度)
workspace.ts 项目检测(Data/Mod.xml / mod.babproj)、索引生命周期、状态栏、重建防抖
settings.ts 配置读取(sdkPath、indexSageXml、definitionMode 等)
language/
xmlParser.ts 带源码偏移的轻量 XML 解析器(格式错误定位、容错)
context.ts 补全上下文分析(元素名/属性名/属性值/内容)
typeContext.ts 上下文感知元素类型解析(resolveElementType 沿解析树逐层解析)
model/
schemaModel.ts schema-model.json 的类型/属性/子元素查询 + 类型名规范化(纯 TS)
schema-model.json 由 tools/xsd-to-model.mjs 生成
asset-types.json 由 tools/extract-asset-types.mjs 生成(TypeId 哈希→类型名)
indexer/
includeResolver.ts Include 路径解析(纯 TSBAB /data /art /audio 顺序)
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
fileScanner.ts 目录遍历缓存 + Include source 候选收集
refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS
indexer.ts 工作区索引器(资产/Define/流/manifest 合并,LRU 解析缓存)
types.ts 共享类型
features/
completion.ts 补全 provider(元素/属性/值,上下文感知)
hover.ts hover provider
navigation.ts 定义/引用/文档链接/大纲
diagnostics.ts 实时诊断
syntaxes/
ra3modxml.tmLanguage.json 注入 source.xml 的领域高亮(纯注入,不替换 XML 主语法)
tools/
xsd-to-model.mjs XSD → schema-model.json
extract-asset-types.mjs OpenSAGE AssetType.cs → asset-types.json
test/
fixtures/minimod 样例 Modinclude 各种情形、同名 ID、嵌套 xi:include、manifest 回退)
*.test.mjs 8 个测试文件(xmlParser / includeResolver / manifestParser /
indexer / schemaModel / refs / typeContext / manifestTypes
```
### 关键设计决策
1. **语言激活范围**:不劫持 `*.xml`。通过 `workspaceContains:**/Data/Mod.xml``**/*.babproj` 激活;语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。
2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);无类型引用匹配任意声明 ID;`inheritFrom` 按可继承类型过滤。
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`
8. **跳转精度**XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
9. **嵌套 `xi:include`**:任意层级处理——目标缺失产生诊断、目标存在则纳入索引;根级 `xpointer` 容器内容按顶层资产索引。
10. **性能**:索引在后台执行;解析结果 LRU 缓存(约 64 个文档);文件保存后防抖全量重建(1.5s),重建期间的新请求标记脏并在完成后重跑;状态栏显示进度与统计。
## 三、实施步骤
1. [x] 调研(Include 规则、XSD、示例项目、规模、工具链)——见本文档第一部分。
2. [x] 脚手架:`package.json``tsconfig.json`、esbuild、`.vscodeignore`、README。
3. [x] 生成模型:`schema-model.json`XSD295 顶层元素 / 1851 类型)与 `asset-types.json`79 个 AssetType 哈希)。
4. [x] 纯 TS 核心:include 解析、manifest 解析、XML 解析封装、索引器、引用解析。
5. [x] 功能层:补全、hover、导航、诊断、大纲、高亮 grammar。
6. [x] 单测(fixture Mod31 个用例全绿)+ 编译 + `vsce package` 打包(ra3-mod-xml-0.1.0.vsix,约 259KB)。
7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 三轮分析)。
## 四、验证结果(实测)
| 项目 | 规模 | 索引耗时 | 资产数 | 说明 |
|---|---|---|---|---|
| AttachTest | 71 文件 | ~0.6s | 35,546manifest 35,322 | 2 个流(static + mapmetadata |
| GenEvoTest | 24 文件 | ~1.8s | 35,392manifest 35,322 | 项目 IDalliedmcv 等)正确收录 |
| Corona | 3,448 文件(+ 非 XML 资产路径) | ~54.5s | 55,305manifest 35,322 | 3 个流、183 个 Define、0 诊断 |
单元测试覆盖:XML 解析(自闭合/容错/偏移)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、无类型引用、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。
> 注:`D:\Mods\CoronaMod` 位于移动硬盘,当前未连接;GenEvoTest / Corona 的回归需在 D: 盘可用时补跑(用户会另行通知)。
## 五、假设与开放问题
- 假设 SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(与 prompts 一致),可在设置中修改。
- 假设补全/导航以“文本语义分析”为主,不做完整 XSD 校验(BAB 才是权威校验器)。
- 开放:是否发布到 VS Code Marketplace(需要 publisher)——本期先保证本地 `vsce package` 可安装。
- 开放:嵌套 `xi:include` 内容的"虚拟合并"进父文档(用于父文档内的补全/诊断感知被内联内容)——当前仅保证目标文件可索引、可导航、缺失可诊断。
+91
View File
@@ -0,0 +1,91 @@
# RA3 Mod XML VSCode 插件 — 情况描述与需求清单
> 由 `prompts` 中的零散描述整理而成。目标产物:一个用于编辑《命令与征服:红色警戒 3》Mod XML 文件的 VS Code 扩展。
## 一、情况描述(整理后)
红警 3 的 Mod 数据以 XML 文件组织,由 EA 的 BinaryAssetBuilderBAB)编译。一个 Mod 项目通常包含两类数据:
1. **基础数据(Static Data**:单位、武器、建筑、贴图、音频、AI、UI 等绝大多数内容。入口文件是 `Data/Mod.xml`
2. **全局数据(Global Data**:无法放进基础数据里的全局配置(游戏设置、全局单例等)。入口是 `Data/additionalmaps/mapmetadata_*.xml` 这类文件——它们原本是 EA 的地图元数据文件,但因为加载最早且允许写任意标签,被 modder 用作“全局数据”入口。
XML 之间的组织靠 `<Include>` 标签,共有三种语义:
| type | 语义 | 说明 |
|---|---|---|
| `reference` | 引用预编译 manifest | 不展开内容。典型用法是 `DATA:static.xml` / `DATA:global.xml` / `DATA:audio.xml` 这三个占位文件,实际对应 SDK `builtmods` 里已编译的 `static.manifest` / `global.manifest` / `audio.manifest` |
| `instance` | 源码级可见 | 不展开进当前文件,但编译时“看得到”目标源码;典型用于 `inheritFrom` 继承 |
| `all` | 内容合并 | 等价于把目标文件内容直接复制进来;`Mod.xml` 通过它递归聚合整个 Mod |
路径解析规则(来自现有工具 `check_duplicate_ids.py` 与编译脚本 `defaultscript.cs`):
- 带前缀 `DATA:` / `ART:` / `AUDIO:` 的路径按固定搜索顺序查找;
- 无前缀的路径相对于当前文件所在目录;
- `ART:` 路径支持“文件名前两个小写字母作为子目录”的匹配(如 `JUAntiShip``ju/JUAntiShip`)。
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction``uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace``Remove`
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
## 二、需求清单
### P0:近期核心功能
1. **语法高亮**:为 RA3 Mod XML 提供可读的高亮;重点补充普通 XML 高亮之外的领域标记(如 `$DEFINE` 常量引用、`inheritFrom` 等)。
2. **自动补全**
- 元素名(按当前父元素的 XSD 定义补全,含顶层资产元素);
- 属性名(按当前元素的 XSD 定义补全,`id` 必填者优先);
- 属性值:
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID
- `inheritFrom` 补全可继承的资产 ID
- 枚举值(XSD `xs:enumeration`);
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
3. **引用提示(Hover**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
4. **引用导航**
- 从引用型属性值跳转到对应资产定义(Go to Definition);
- 查找某资产 ID 的所有引用(Find All References);
- `<Include source>` / `xi:include href` 直接打开目标文件;
- `inheritFrom` 跳转到被继承元素;
- 文档大纲:显示文件内的顶层资产元素。
5. **错误检查(实时诊断)**
- XML 格式错误(well-formedness);
- 未知元素 / 未知属性(相对 XSD 模型);
- 缺失必填 `id`(顶层资产);
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
- `<Include>` 目标文件找不到、Include 循环;
- `$DEFINE` 未定义。
**补充(manifest 解析,支持 include reference 后的补全/导航/诊断)**
-`Mod.xml`(或其他文件)用 `<Include type="reference" source="DATA:static.xml" />` 引用占位文件时,实际内容来自 SDK `builtmods` 下对应的已编译二进制 manifest(`static.manifest` / `global.manifest` / `audio.manifest`)。
- 通过解析这些 manifest,可以得到其包含的全部资产(名称、类型、来源文件),从而:
- **代码补全**:例如 reference 了 `audio.xml` 后,所有音频资产 ID 都能出现在引用型属性(如 `AudioEventRef`)的补全里;
- **引用导航/悬停**:能定位资产来自哪个 manifest、哪个源文件;
- **诊断**:能把“引用了 manifest 中的 ID”识别为已解析,而不是误报未解析引用。
- manifest 为二进制格式,解析逻辑参考 OpenSAGE `ManifestFile.cs`(用户已在本工作区 `OpenSAGE/` 克隆并切到指定 commit)。关键格式要点:
- 头部含版本(5/6/7)、端序标志、各缓冲区大小、资产数量;
- 每个资产条目含 `TypeId`(哈希)、`NameOffset``SourceFileNameOffset` 等;
- `TypeId` 哈希 → 类型名的映射来自 OpenSAGE 的 `AssetType` 枚举(本工作区可提取);
- 资产名与源文件名各自存放在独立的空字符结尾字符串缓冲区中。
### P1:非近期目标(本期不做,但预留扩展点)
6. **高效搜索**Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。
7. **索引机制的复用性**:希望索引不仅能服务 VS Code 插件,也能被其他工具(如搜索、静态分析)复用,因此索引/解析核心应设计成与编辑器无关的纯模块。
## 三、环境与参考资源
- SDK`C:\Apps\RA3-MODSDK-X`(含 `defaultscript.cs` 编译脚本、`Schemas/xsd``SageXml` 原版源码、`builtmods` 编译产物)。
- 中小项目:`C:\Apps\RA3-MODSDK-X\Mods\AttachTest``D:\Mods\CoronaMod\mods\mods\GenEvoTest`
- 大型项目:`D:\Mods\CoronaMod\mods\mods\corona`(自带 `xsd/`)。
- 现有工具:工作区 `check_duplicate_ids.py`include 解析与重复 ID 检测的参考实现)。
- manifest 格式参考:OpenSAGE `src/OpenSage.Game/Data/StreamFS/ManifestFile.cs`commit `d45d361`,最新分支已移除该文件)。本机网络受限未能拉取,且 manifest 为压缩/哈希的二进制,本期不实现其解析。
## 四、验收标准
- 在 AttachTest / GenEvoTest 上开箱即用(高亮、补全、跳转、诊断)。
- 在 Corona 规模的目录上不卡 UI:索引在后台执行、保存文件后增量更新。
- 纯解析/索引核心不依赖 VS Code API,可被其他工具复用。
- 可用 `vsce package` 打出可安装的 `.vsix`
+23
View File
@@ -0,0 +1,23 @@
import * as esbuild from "esbuild";
const watch = process.argv.includes("--watch");
const ctx = await esbuild.context({
entryPoints: ["./src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
external: ["vscode"],
format: "cjs",
platform: "node",
target: "es2022",
sourcemap: true,
logLevel: "info",
});
if (watch) {
await ctx.watch();
console.log("Watching for changes...");
} else {
await ctx.rebuild();
await ctx.dispose();
}
+2962
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
{
"name": "ra3-mod-xml",
"displayName": "RA3 Mod XML",
"description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"version": "0.1.0",
"publisher": "ra3-mod-xml",
"license": "MIT",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages",
"Linters"
],
"keywords": [
"red alert 3",
"ra3",
"mod",
"sage",
"xml",
"binaryassetbuilder"
],
"main": "./dist/extension.js",
"activationEvents": [
"workspaceContains:**/Data/Mod.xml",
"workspaceContains:**/mod.babproj",
"workspaceContains:**/*.babproj"
],
"contributes": {
"grammars": [
{
"scopeName": "source.ra3modxml",
"path": "./syntaxes/ra3modxml.tmLanguage.json",
"injectTo": [
"source.xml"
]
}
],
"configuration": {
"title": "RA3 Mod XML",
"properties": {
"ra3modxml.sdkPath": {
"type": "string",
"default": "C:\\Apps\\RA3-MODSDK-X",
"description": "Path to the RA3 Mod SDK root. Used to resolve DATA:/ART:/AUDIO: includes and to index vanilla SageXml sources."
},
"ra3modxml.indexSageXml": {
"type": "boolean",
"default": true,
"description": "Index vanilla game XML sources shipped with the SDK (SageXml) so completions/references can resolve vanilla asset ids."
},
"ra3modxml.reportUnresolvedReferences": {
"type": "string",
"enum": [
"warning",
"information",
"none"
],
"default": "warning",
"description": "Severity for attribute references (CommandSet=..., inheritFrom=...) that cannot be resolved in the index. IDs that only exist in compiled manifests cannot be resolved yet."
},
"ra3modxml.diagnoseUnknownElements": {
"type": "boolean",
"default": true,
"description": "Report element/attribute names that are not known from the bundled XSD model."
},
"ra3modxml.definitionMode": {
"type": "string",
"enum": [
"all",
"project-only"
],
"default": "all",
"description": "Go-to-definition candidates: 'all' lists the mod definition together with any vanilla (SDK/manifest) definitions (mod first); 'project-only' jumps directly to the mod definition and skips vanilla candidates when the id is defined in the project."
},
"ra3modxml.additionalDataSearchPaths": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Extra absolute directories appended to the DATA: search path (checked after the project folders)."
}
}
},
"commands": [
{
"command": "ra3modxml.reindex",
"title": "RA3 Mod XML: Re-index workspace"
},
{
"command": "ra3modxml.openIndexReport",
"title": "RA3 Mod XML: Show index report"
}
]
},
"scripts": {
"build": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"generate-model": "node tools/xsd-to-model.mjs",
"pretest": "tsc",
"test": "node --test \"test/*.test.mjs\"",
"package": "npm run build && vsce package"
},
"dependencies": {
"fast-xml-parser": "^4.5.0"
},
"devDependencies": {
"@vscode/vsce": "^2.30.0",
"@types/node": "^20.14.0",
"@types/vscode": "^1.85.0",
"esbuild": "^0.21.5",
"typescript": "^5.5.0"
}
}
+57
View File
@@ -0,0 +1,57 @@
我希望创建一个用于红警3 Modding XML 的 VSCode 扩展插件。这个扩展插件将提供以下功能:
1. 语法高亮:为红警3 Modding XML 文件提供语法高亮显示,使代码更易读。可能许多 XML 插件已经支持这个功能。
2. 自动补全:根据红警3 Modding XML 的语法规则,提供自动补全功能,帮助用户快速编写代码。
3. 引用提示:在用户输入时,提供相关的引用提示,帮助用户了解可用的标签和属性。
4. 引用导航:允许用户快速跳转到相关的标签或属性定义,提升代码浏览和编辑效率。
5. 错误检查:在用户编写代码时,实时检查语法错误,并提供错误提示,帮助用户快速修复问题。
[非近期目标] 6. 搜索:在 mod 项目巨大时,提供高效的搜索方式(直接搜索可能会很慢),这种特殊需求可能有很多需要考虑的部分,需要后期仔细设计,暂时不考虑。或者可以留一个极简的可拓展的搜索接口。
[非近期目标] 这些功能,可能会需要一个高效的索引 mod xml 内容的机制,才能实现 VSCode 拓展。那么这个机制能否用在 VSCode 拓展以外的部分呢?之前提到的“搜索”可能是一个例子,假如要能支持其他使用方式(而不仅仅是一个 VSCode 拓展),该怎么设计呢?
典型红警3 Mod XML 里有两种较为重要的入口:
1. Data/Mod.xml
这个是最主要的入口文件,定义了绝大部分内容
2. Data/mapmetadata_*.xml
这个本来是 EA 为红警3 提供的地图元数据文件,按照EA的原设计,应该是用来定义 <MapMetadata> 标签的内容。
但是这些 mapmetadata 文件在游戏中是最早被加载的,而且 XML 里也不止可以写 MapMetadata 标签,还可以写其他标签,所以这些最早加载的 XML 可以用来初始化游戏的很多全局配置,所以被玩家用来实现“全局数据”的配置
因此,mod 的 XML 文件可以分为两类:
1. 基础数据(Static Data),包含模组绝大多数素材、贴图、音频、单位定义、配置等
2. 全局数据(Global Data),包含一些无法卸载基础数据里的全局配置
从入口文件开始,XML 会包含其他 XML,主要方式是通过 <Include> 标签,
有三种方式:
1. reference:引用其他 XML 文件的内容。本身不会包含被引用 XML 的内容,只是引用了其他 XML 的内容。
最典型的使用方式是 reference static.xml, global.xml, audio.xml。
这三个 XML 一般用来代表游戏本体(而非 mod 的)基础数据、全局数据和音频数据。
这三个文件本身是占位符(空文件)。但是等价于 reference 了一个包含所有定义的 XML
实际上 BinaryAssetBuilder 会去引用 SDK 文件夹 builtmods 里的已编译二进制文件:
C:\Apps\RA3-MODSDK-X\builtmods,这里面的 static.manifest、global.manifest、audio.manifest
当前 XML 编译成 manifest 之后,会添加对应 manifest 的引用。
对于 manifest 的格式,可以参考这个开源代码:https://github.com/OpenSAGE/OpenSAGE/blob/d45d361e8f41211e34cc219d72d0502a6b2d5694/src/OpenSage.Game/Data/StreamFS/ManifestFile.cs (注意:最新版本的分支不再有这个文件夹,链接里的 commit 是最后一刻可以参考的版本)
2. instance:引用包含其他 XML 文件的源码。本身不会包含被引用 XML 的内容,只是在编译过程中能“看到”那个XML的源码。这种情况下被引用的是一个真实存在的 XML 文件,不是占位符,也不需要预编译的 manifest 文件。当前 XML 编译成 manifest 之后,不会因为 instance 就去引用另一个 manifest 文件。典型的使用方式是引用游戏本体的源码、使用 inheritFrom 进行继承。
3. all:等同于把目标 XML 的内容直接复制到当前 XML 里。因此 mod.xml 会递归使用 all 方式引用所有的 XML 文件,最终 mod.xml 会包含 mod 的所有 XML 内容。
inheritFrom 代表继承另一个 XML 元素的内容,默认拥有对象元素的所有内容。具体的继承细节还可以被uri:ea.com:eala:asset:instance:joinAction(一般是 xai:joinAction)控制
关于 include 规则,可以参考我之前开发的一个“检测重复id”的python小工具:它也需要解析 include 才能读取所有 xml 才能检测重复 id。你可以参考这个工具的源码,了解 include 的解析规则:check_duplicate_id.py
你还可以参考 defaultscript.cs
这里面有 mod 的编译脚本,尤其是它如何调用 BinaryAssetBuilder 的
可以重点关注的步骤是建立基础数据(Static Data)和建立全局数据(Global Data
但是需要注意:这里面既有较为标准规范、适合参考的部分,也有一些是玩家 modder 们自行加上去的 hack,可能并不适合直接参考。
例如:玩家为了方便制作增量补丁,定义了 Art1、Data1 这样的文件夹结构
除此之外游戏模组还有一些和 XML 关系不大的部分(例如 HLSL),我们可以暂时不考虑这些部分。
你在分析的 defaultscript.cs 的时候,需要注意区分哪些是应该实际参考的
实际上有用的部分应该是和 check_duplicate_id.py 里类似的。
Mod SDK 里既提供编译后的原版游戏本体 manifest(用来支持 include reference),也提供原版游戏本体的部分源码(用来支持 include instance,或者就是供 modder 参考)。你可以参考这些源码,这些源码按照 defaultscript.cs 或者 check_duplicate_id.py 的 include 规则。例如,SDK 的 SageXml 文件夹对应 DATA:,里面的 XML 文件就是游戏本体的源码;游戏本体的美术素材(art)和音频素材(audio)也有对应的文件,只不过它们一般是空的(主要靠 reference 了)
游戏的 XML 格式由 XSD 规定,MOD SDK 自带 XSDC:\Apps\RA3-MODSDK-X\Schemas\xsd\CnC3Types.xsd
可供参考的中小项目:
D:\Mods\CoronaMod\mods\mods\GenEvoTest
C:\Apps\RA3-MODSDK-X\Mods\AttachTest
可供参考的大型项目:
D:\Mods\CoronaMod\mods\mods\corona\
该大型项目自带自己的 xsd: D:\Mods\CoronaMod\mods\mods\corona\xsd\CnC3Types.xsd
以上,提供了很多外部项目进行参考,你被鼓励读取并考虑把文件复制到工作区里进行分析(本工作区内你可以随意操作),以避免对这些外部项目进行破坏性操作
+141
View File
@@ -0,0 +1,141 @@
import * as vscode from "vscode";
import { ModWorkspace } from "./workspace";
import { Ra3CompletionProvider } from "./features/completion";
import { Ra3HoverProvider } from "./features/hover";
import {
Ra3DefinitionProvider,
Ra3DocumentLinkProvider,
Ra3DocumentSymbolProvider,
Ra3ReferenceProvider,
} from "./features/navigation";
import { Ra3Diagnostics } from "./features/diagnostics";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
export function activate(context: vscode.ExtensionContext): void {
const ws = new ModWorkspace(context);
context.subscriptions.push(ws);
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
XML_SELECTOR,
new Ra3CompletionProvider(ws),
"<",
'"',
"=",
":",
".",
"/",
),
);
context.subscriptions.push(
vscode.languages.registerHoverProvider(XML_SELECTOR, new Ra3HoverProvider(ws)),
);
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(
XML_SELECTOR,
new Ra3DefinitionProvider(ws),
),
);
context.subscriptions.push(
vscode.languages.registerReferenceProvider(
XML_SELECTOR,
new Ra3ReferenceProvider(),
),
);
context.subscriptions.push(
vscode.languages.registerDocumentLinkProvider(
XML_SELECTOR,
new Ra3DocumentLinkProvider(ws),
),
);
context.subscriptions.push(
vscode.languages.registerDocumentSymbolProvider(
XML_SELECTOR,
new Ra3DocumentSymbolProvider(),
),
);
const diagnostics = new Ra3Diagnostics(ws);
context.subscriptions.push(diagnostics);
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
const scheduleDiagnostics = (doc: vscode.TextDocument) => {
if (doc.languageId !== "xml") return;
const key = doc.uri.toString();
const existing = diagnosticTimers.get(key);
if (existing) clearTimeout(existing);
diagnosticTimers.set(
key,
setTimeout(() => {
diagnosticTimers.delete(key);
void diagnostics.update(doc);
}, 500),
);
};
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((e) => {
scheduleDiagnostics(e.document);
}),
);
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((doc) => {
if (doc.languageId === "xml") void diagnostics.update(doc);
}),
);
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor) => {
if (editor?.document.languageId === "xml") void diagnostics.update(editor.document);
}),
);
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
diagnostics.clear(doc.uri);
}),
);
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((doc) => {
if (doc.languageId !== "xml") return;
ws.scheduleRebuild();
void diagnostics.update(doc);
}),
);
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("ra3modxml")) ws.scheduleRebuild();
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild()),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
const idx = ws.index;
if (!idx) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml.",
);
return;
}
const s = idx.stats;
void vscode.window.showInformationMessage(
`RA3 Mod XML index\n` +
`Project: ${s.projectDir}\n` +
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed)\n` +
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`,
{ modal: false },
);
}),
);
void ws.initialize();
}
export function deactivate(): void {
// All subscriptions are disposed by VS Code.
}
+376
View File
@@ -0,0 +1,376 @@
import * as vscode from "vscode";
import { parseXml, type XmlElement } from "../language/xmlParser";
import { analyzeContext, type CompletionContext } from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex, AssetDef } from "../indexer/types";
const MAX_VALUE_ITEMS = 400;
export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
constructor(private ws: ModWorkspace) {}
async provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[]> {
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, offset);
const idx = this.ws.index;
switch (ctx.kind) {
case "element-name":
return this.elementNameItems(ctx, document, position);
case "attribute-name":
return this.attributeNameItems(ctx, document, position);
case "attribute-value":
return idx ? this.valueItems(ctx, document, position, idx) : [];
case "content":
return idx ? this.contentItems(ctx, document, position, idx) : [];
default:
return [];
}
}
// ── Element name ──────────────────────────────────────────────────
private elementNameItems(
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
): vscode.CompletionItem[] {
if (ctx.closing) return [];
const parent = ctx.element?.parent ?? null;
const names = this.childrenOf(parent);
if (!names.length) return [];
const start = ctx.element
? document.offsetAt(
document.positionAt(ctx.element.start + (ctx.closing ? 2 : 1)),
)
: document.offsetAt(position);
const range = new vscode.Range(document.positionAt(start), position);
const items: vscode.CompletionItem[] = [];
for (const child of names) {
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
item.range = range;
const type = model.elementTypeName(child.name);
const info = type ? model.typeInfo(type) : undefined;
const docText =
child.doc ||
(info?.kind === "complex" ? info.doc : "") ||
(type ? `Type: ${type}` : "");
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.insertText = this.elementSnippet(child.name, type);
items.push(item);
}
return items;
}
private childrenOf(
parent: XmlElement | null,
): { name: string; type: string | null; doc: string }[] {
if (!parent) {
return [
{ name: "AssetDeclaration", type: null, doc: "Root element of every RA3 asset file" },
];
}
const parentType = resolveElementType(parent);
const children = parentType
? model.childrenOfType(parentType)
: model.childrenOfElement(parent.name);
if (children.length) {
return children.map((c) => ({ name: c.name, type: c.type, doc: c.doc }));
}
return [];
}
private elementSnippet(name: string, type: string | null): vscode.SnippetString {
if (model.isTopLevelElement(name)) {
return new vscode.SnippetString(`<${name} id="$1">\n\t$0\n</${name}>`);
}
const info = type ? model.typeInfo(type) : undefined;
const hasChildren = info?.kind === "complex" && info.children.length > 0;
if (hasChildren) {
return new vscode.SnippetString(`<${name}>\n\t$0\n</${name}>`);
}
return new vscode.SnippetString(`<${name} />`);
}
// ── Attribute name ────────────────────────────────────────────────
private attributeNameItems(
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
): vscode.CompletionItem[] {
const el = ctx.element;
if (!el) return [];
const elType = resolveElementType(el);
const attrs = model.attributesOfType(elType);
const used = new Set(ctx.existingAttrs.map((a) => a.toLowerCase()));
const items: vscode.CompletionItem[] = [];
const wordStart = findAttributeWordStart(document, position, el);
const range = new vscode.Range(document.positionAt(wordStart), position);
for (const attr of attrs) {
if (used.has(attr.name.toLowerCase())) continue;
const item = new vscode.CompletionItem(attr.name, vscode.CompletionItemKind.Property);
item.range = range;
item.sortText = attr.required ? "0" + attr.name : "1" + attr.name;
const md = new vscode.MarkdownString();
if (attr.doc) md.appendMarkdown(attr.doc + "\n\n");
if (attr.required) md.appendMarkdown(`**Required** \n`);
if (attr.refType) md.appendMarkdown(`References: \`${attr.refType}\` \n`);
if (attr.enumValues.length)
md.appendMarkdown(`Values: ${attr.enumValues.join(", ")} \n`);
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
item.documentation = md;
if (attr.required) {
item.insertText = attr.name === "id" ? 'id="$1"' : `${attr.name}="$1"`;
} else {
item.insertText = `${attr.name}="$1"`;
}
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
items.push(item);
}
// Namespace/instance helpers.
if (!used.has("xai:joinaction")) {
const j = new vscode.CompletionItem("xai:joinAction", vscode.CompletionItemKind.Property);
j.range = range;
j.insertText = 'xai:joinAction="$1"';
j.detail = "Instance join action";
j.documentation = new vscode.MarkdownString(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
);
items.push(j);
}
if (!used.has("xmlns:xai")) {
const ns = new vscode.CompletionItem("xmlns:xai", vscode.CompletionItemKind.Property);
ns.range = range;
ns.insertText = 'xmlns:xai="uri:ea.com:eala:asset:instance"';
ns.detail = "xai namespace";
items.push(ns);
}
return items;
}
// ── Attribute value ───────────────────────────────────────────────
private valueItems(
ctx: CompletionContext,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex,
): vscode.CompletionItem[] {
const el = ctx.element;
const attr = ctx.attr;
if (!el || !attr) return [];
const prefix = ctx.valuePrefix;
const endOffset = attr.quoteEnd > attr.valueEnd ? attr.valueEnd : document.offsetAt(position);
const valueRange = new vscode.Range(
document.positionAt(attr.valueStart),
document.positionAt(Math.max(attr.valueStart, endOffset)),
);
const make = (
label: string,
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
) => {
const item = new vscode.CompletionItem(label, kind);
item.range = valueRange;
item.insertText = label;
item.detail = detail;
if (doc) item.documentation = new vscode.MarkdownString(doc);
return item;
};
const isInclude = el.name === "Include";
const attrName = attr.name.toLowerCase();
// Include type / source
if (isInclude && attrName === "type") {
return ["reference", "instance", "all"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "Include type"),
);
}
if (isInclude && attrName === "source") {
return this.includeSourceItems(idx, prefix, make);
}
if (attrName === "xai:joinaction" || attrName === "joinaction") {
return ["Replace", "Remove"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "xai:joinAction"),
);
}
const elType = resolveElementType(el);
const attrInfo = model
.attributesOfType(elType)
.find((a) => a.name.toLowerCase() === attrName);
// inheritFrom: same element type first, then everything.
if (attrName === "inheritfrom") {
return this.assetIdItems(idx, el.name, null, prefix, make);
}
if (attrInfo?.refType) {
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
}
if (attrInfo?.enumValues?.length) {
return attrInfo.enumValues
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
}
if (attrInfo?.isBoolean) {
return ["true", "false"]
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
}
if (attrInfo?.allowsDefine) {
return this.defineItems(idx, prefix, make);
}
return [];
}
private includeSourceItems(
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const lower = prefix.toLowerCase();
const candidates = idx.sourceCandidates
.filter((c) => c.source.toLowerCase().includes(lower))
.slice(0, MAX_VALUE_ITEMS);
const priority: Record<string, number> = { "": 0, DATA: 1, ART: 2, AUDIO: 3 };
candidates.sort(
(a, b) =>
(priority[a.prefix ?? ""] ?? 4) - (priority[b.prefix ?? ""] ?? 4) ||
a.source.localeCompare(b.source),
);
return candidates.map((c) => {
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
item.detail = c.path;
item.documentation = new vscode.MarkdownString(
`\`${c.prefix ?? "relative"}\` · ${c.path}`,
);
return item;
});
}
private assetIdItems(
idx: ModIndex,
selfType: string | null,
refType: string | null,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
const consider = (def: AssetDef) => {
if (!def.id.toLowerCase().startsWith(lower)) return;
let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
if (def.origin === "project") score -= 0.2;
scored.push({ def, score });
};
const targetType = selfType ?? refType;
if (!targetType) {
for (const list of idx.assetsById.values()) for (const d of list) consider(d);
} else {
for (const [typeName, byId] of idx.assets) {
if (!model.isAssignableTo(typeName, targetType)) continue;
for (const list of byId.values()) for (const d of list) consider(d);
}
}
scored.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
return scored.slice(0, MAX_VALUE_ITEMS).map(({ def }) => {
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
const doc = new vscode.MarkdownString();
doc.appendCodeblock(def.id);
doc.appendMarkdown(`**Type**: ${def.type} \n`);
if (def.manifestSource) doc.appendMarkdown(`**Source**: ${def.manifestSource} \n`);
doc.appendMarkdown(`**Origin**: ${origin}`);
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
});
}
private defineItems(
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = [];
for (const [key, defs] of idx.defines) {
if (!key.includes(lower)) continue;
const def = defs[0];
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
item.insertText = label;
items.push(item);
}
return items.slice(0, MAX_VALUE_ITEMS);
}
// ── Element content ───────────────────────────────────────────────
private contentItems(
ctx: CompletionContext,
_document: vscode.TextDocument,
_position: vscode.Position,
_idx: ModIndex,
): vscode.CompletionItem[] {
const el = ctx.element;
if (!el) return [];
// Reuse element-name suggestions with a plain replacement range.
const elType = resolveElementType(el);
const names = elType ? model.childrenOfType(elType) : model.childrenOfElement(el.name);
const items: vscode.CompletionItem[] = [];
for (const child of names) {
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
item.insertText = this.elementSnippet(child.name, child.type);
const type = child.type;
const info = type ? model.typeInfo(type) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
if (doc) item.documentation = new vscode.MarkdownString(doc);
items.push(item);
}
return items;
}
}
function findAttributeWordStart(
document: vscode.TextDocument,
position: vscode.Position,
el: { start: number },
): number {
const offset = document.offsetAt(position);
const tagStart = el.start;
let i = offset;
const text = document.getText();
while (i > tagStart) {
const c = text[i - 1];
if (/[\s=<>"/]/.test(c)) break;
i--;
}
return i;
}
+339
View File
@@ -0,0 +1,339 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { LineMap, parseXml, type XmlElement } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../indexer/refs";
export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection;
constructor(private ws: ModWorkspace) {
this.collection = vscode.languages.createDiagnosticCollection("ra3modxml");
}
async update(document: vscode.TextDocument): Promise<void> {
const idx = this.ws.index;
if (!idx) {
this.collection.set(document.uri, []);
return;
}
const text = document.getText();
const lineMap = new LineMap(text);
const doc = parseXml(text);
const diags: vscode.Diagnostic[] = [];
for (const err of doc.errors) {
diags.push(
this.diag(
new vscode.Range(
new vscode.Position(err.line, err.character),
new vscode.Position(err.line, err.character + 1),
),
err.message,
vscode.DiagnosticSeverity.Error,
"xml-syntax",
),
);
}
if (doc.root) {
this.checkElements(doc.root, doc, lineMap, idx, document, diags);
}
this.collection.set(document.uri, diags);
}
clear(uri: vscode.Uri): void {
this.collection.delete(uri);
}
dispose(): void {
this.collection.dispose();
}
private checkElements(
root: XmlElement,
doc: { elements: XmlElement[] },
lineMap: LineMap,
idx: ModIndex,
document: vscode.TextDocument,
diags: vscode.Diagnostic[],
): void {
const settings = this.ws.settings;
const fileDuplicates = new Map<string, { line: number }>();
for (const el of doc.elements) {
const local = localName(el.name);
const isTopLevel = el.parent === root && !["Tags", "Includes", "Defines"].includes(local);
const range = tagRange(document, el);
// Top-level assets must have an id.
if (isTopLevel) {
const idAttr = el.attrs.find((a) => a.name === "id");
if (!idAttr || !idAttr.value) {
diags.push(
this.diag(
range,
`Top-level asset <${local}> requires an id attribute`,
vscode.DiagnosticSeverity.Error,
"missing-id",
),
);
} else {
const key = `${local.toLowerCase()}:${idAttr.value.toLowerCase()}`;
const prev = fileDuplicates.get(key);
if (prev) {
const where = new vscode.Range(
document.positionAt(idAttr.valueStart),
document.positionAt(idAttr.valueEnd),
);
diags.push(
this.diag(
where,
`Duplicate id "${idAttr.value}" for <${local}> (also defined on line ${prev.line})`,
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
);
} else {
fileDuplicates.set(key, { line: lineMap.positionAt(el.start).line + 1 });
}
this.checkCrossFileDuplicate(
local,
idAttr.value,
document,
idx,
diags,
);
}
}
// Unknown element.
if (settings.diagnoseUnknownElements && !el.name.startsWith("xi:")) {
const knownType = model.elementTypeName(local);
if (!knownType) {
diags.push(
this.diag(
range,
`Unknown element <${local}> (not in the RA3 XSD model)`,
vscode.DiagnosticSeverity.Warning,
"unknown-element",
),
);
}
}
// Attributes.
const elType = resolveElementType(el);
const knownAttrs = model.attributesOfType(elType);
const knownNames = new Set(knownAttrs.map((a) => a.name));
for (const attr of el.attrs) {
const aName = attr.name;
if (aName.startsWith("xmlns") || aName.startsWith("xai:") || aName.startsWith("xi:")) {
continue;
}
if (settings.diagnoseUnknownElements && !knownNames.has(aName)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(attr.nameStart),
document.positionAt(attr.nameEnd),
),
`Unknown attribute "${aName}" for <${local}>`,
vscode.DiagnosticSeverity.Warning,
"unknown-attribute",
),
);
}
if (!attr.hasValue) continue;
this.checkValueReferences(
elType,
attr.name,
attr.value,
attr,
document,
idx,
diags,
);
}
// Include-specific checks.
if (local === "Include") {
this.checkInclude(el, document, idx, diags);
}
}
}
private checkCrossFileDuplicate(
type: string,
id: string,
document: vscode.TextDocument,
idx: ModIndex,
diags: vscode.Diagnostic[],
): void {
const byType = idx.assets.get(type);
const defs = byType?.get(id.toLowerCase());
if (!defs || defs.length < 2) return;
const self = defs.filter(
(d) =>
d.origin === "project" &&
!d.viaInstance &&
d.file.toLowerCase() === document.uri.fsPath.toLowerCase(),
);
if (!self.length) return;
const others = defs.filter(
(d) =>
d.origin === "project" &&
!d.viaInstance &&
d.file.toLowerCase() !== document.uri.fsPath.toLowerCase() &&
d.stream === self[0].stream,
);
for (const other of others) {
const range = self[0].line > 0
? new vscode.Range(new vscode.Position(self[0].line - 1, 0), new vscode.Position(self[0].line - 1, 1))
: new vscode.Range(0, 0, 0, 1);
diags.push(
this.diag(
range,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})`,
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
);
}
}
private checkValueReferences(
elType: string | null,
attrName: string,
value: string,
attr: { valueStart: number; valueEnd: number },
document: vscode.TextDocument,
idx: ModIndex,
diags: vscode.Diagnostic[],
): void {
if (!value) return;
const range = new vscode.Range(
document.positionAt(attr.valueStart),
document.positionAt(attr.valueEnd),
);
// Undefined $DEFINE references.
const defineRe = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = defineRe.exec(value)) !== null) {
if (!idx.defines.has(m[1].toLowerCase())) {
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"`,
vscode.DiagnosticSeverity.Warning,
"undefined-define",
),
);
}
}
if (value.startsWith("$") || value.startsWith("=")) return;
const severity = this.ws.settings.reportUnresolvedReferences;
if (severity === "none") return;
if (!isReferenceAttributeOfType(elType, attrName)) return;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) return;
const anyDef = idx.assetsById.has(value.toLowerCase());
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
const expected = attrRef?.refType
? `of type \`${attrRef.refType}\``
: attrRef?.isRef
? "of the expected declared type"
: "matching";
diags.push(
this.diag(
range,
anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information,
"unresolved-reference",
),
);
}
private checkInclude(
el: XmlElement,
document: vscode.TextDocument,
idx: ModIndex,
diags: vscode.Diagnostic[],
): void {
const typeAttr = el.attrs.find((a) => a.name === "type");
const sourceAttr = el.attrs.find((a) => a.name === "source");
if (typeAttr?.hasValue && !["reference", "instance", "all"].includes(typeAttr.value)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(typeAttr.valueStart),
document.positionAt(typeAttr.valueEnd),
),
`Invalid Include type "${typeAttr.value}" (expected reference, instance or all)`,
vscode.DiagnosticSeverity.Error,
"include-type",
),
);
}
if (!sourceAttr?.hasValue) return;
const resolved = resolveSource(
sourceAttr.value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
);
if (!resolved.path && !idx.sourceCandidates.some((c) => c.source === sourceAttr.value)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(sourceAttr.valueStart),
document.positionAt(sourceAttr.valueEnd),
),
`Include target not found: ${sourceAttr.value}`,
vscode.DiagnosticSeverity.Warning,
"include-not-found",
),
);
}
}
private diag(
range: vscode.Range,
message: string,
severity: vscode.DiagnosticSeverity,
code: string,
): vscode.Diagnostic {
const d = new vscode.Diagnostic(range, message, severity);
d.code = code;
d.source = "RA3 Mod XML";
return d;
}
}
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function tagRange(document: vscode.TextDocument, el: XmlElement): vscode.Range {
return new vscode.Range(
document.positionAt(el.start),
document.positionAt(el.startTagEnd),
);
}
+185
View File
@@ -0,0 +1,185 @@
import * as vscode from "vscode";
import { findElementAt, parseXml } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import { dirname } from "node:path";
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
export class Ra3HoverProvider implements vscode.HoverProvider {
constructor(private ws: ModWorkspace) {}
async provideHover(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Hover | null> {
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
// Attribute name.
for (const attr of el.attrs) {
if (offset >= attr.nameStart && offset <= attr.nameEnd) {
return this.attributeHover(elType, attr.name);
}
}
// Attribute value.
for (const attr of el.attrs) {
if (attr.hasValue && offset >= attr.valueStart && offset <= attr.valueEnd) {
return this.valueHover(el, elType, attr.name, attr.value, document, this.ws.index);
}
}
// Element name.
const nameStart = el.start + 1;
if (offset >= nameStart && offset <= nameStart + el.name.length) {
return this.elementHover(el.name);
}
return null;
}
private elementHover(name: string): vscode.Hover | null {
const type = model.elementTypeName(name);
const info = type ? model.typeInfo(type) : undefined;
const md = new vscode.MarkdownString();
md.appendCodeblock(`<${name}>`, "xml");
if (model.isTopLevelElement(name)) md.appendMarkdown(`**Top-level asset element** \n`);
if (info?.kind === "complex") {
if (info.doc) md.appendMarkdown(`${info.doc} \n`);
md.appendMarkdown(
`Attributes: ${info.attributes.length} · Children: ${info.children.length} \n`,
);
if (info.base) md.appendMarkdown(`Extends: \`${info.base}\``);
} else if (info?.kind === "simple") {
md.appendMarkdown(`Simple type: \`${type}\``);
} else if (type) {
md.appendMarkdown(`Type: \`${type}\``);
} else {
md.appendMarkdown("Not found in the bundled XSD model.");
}
return new vscode.Hover(md);
}
private attributeHover(elementType: string | null, attrName: string): vscode.Hover | null {
const attrs = model.attributesOfType(elementType);
const attr = attrs.find((a) => a.name === attrName);
const md = new vscode.MarkdownString();
md.appendCodeblock(`${attrName}=""`, "xml");
if (!attr) {
if (/^(xmlns|xai:)/.test(attrName)) {
md.appendMarkdown(`Namespace/instance attribute.`);
return new vscode.Hover(md);
}
md.appendMarkdown("Unknown attribute for this element.");
return new vscode.Hover(md);
}
if (attr.doc) md.appendMarkdown(`${attr.doc} \n`);
if (attr.required) md.appendMarkdown(`**Required** \n`);
if (attr.refType) md.appendMarkdown(`References assets of type \`${attr.refType}\` \n`);
if (attr.enumValues.length)
md.appendMarkdown(`Values: \`${attr.enumValues.join("`, `")}\` \n`);
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
if (attr.allowsDefine) md.appendMarkdown(`May use \`$DEFINE\` constants \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
return new vscode.Hover(md);
}
private valueHover(
el: { name: string },
elType: string | null,
attrName: string,
value: string,
document: vscode.TextDocument,
idx: ModIndex | null,
): vscode.Hover | null {
const md = new vscode.MarkdownString();
// $DEFINE reference.
const defineMatch = /\$([A-Za-z_][A-Za-z0-9_]*)/.exec(value);
if (defineMatch && idx) {
const defs = idx.defines.get(defineMatch[1].toLowerCase());
if (defs?.length) {
const d = defs[0];
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
return new vscode.Hover(md);
}
}
// Include source.
if (el.name === "Include" && attrName === "source") {
const resolved = idx
? resolveSource(
value,
dirname(document.uri.fsPath),
buildSearchPaths(idx.sdkDir, idx.projectDir),
).path
: null;
if (resolved) {
md.appendMarkdown(`**Include source** \n`);
md.appendCodeblock(resolved);
return new vscode.Hover(md);
}
const cand = idx?.sourceCandidates.find((c) => c.source === value);
if (cand) {
md.appendMarkdown(`**Include source** \n`);
md.appendCodeblock(cand.path);
return new vscode.Hover(md);
}
md.appendMarkdown(`Include source: \`${value}\` (not in candidate index)`);
return new vscode.Hover(md);
}
// Asset reference / inheritFrom.
if (idx) {
if (!isReferenceAttributeOfType(elType, attrName)) return null;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
}
return new vscode.Hover(md2);
}
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
const expected = attrRef?.refType
? ` of type \`${attrRef.refType}\``
: attrRef?.isRef
? " of the expected declared type"
: "";
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
return new vscode.Hover(md);
}
return null;
}
}
function relativePath(document: vscode.TextDocument, abs: string): string {
const root = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath;
if (!root) return abs;
const rel = abs.toLowerCase().startsWith(root.toLowerCase())
? abs.slice(root.length + 1)
: abs;
return rel;
}
+330
View File
@@ -0,0 +1,330 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
resolveSource,
type SearchPaths,
} from "../indexer/includeResolver";
import { resolveReferenceTargetsForType } from "../indexer/refs";
import type { ModWorkspace } from "../workspace";
import type { AssetDef, ModIndex } from "../indexer/types";
function searchPathsFor(idx: ModIndex): SearchPaths {
return buildSearchPaths(idx.sdkDir, idx.projectDir);
}
// ── Go to definition ────────────────────────────────────────────────
export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
constructor(private ws: ModWorkspace) {}
async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.Location | vscode.Location[] | null> {
const idx = this.ws.index;
if (!idx) return null;
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const el = findElementAt(doc, offset);
if (!el) return null;
const elType = resolveElementType(el);
const attr = el.attrs.find(
(a) => a.hasValue && offset >= a.valueStart && offset <= a.valueEnd,
);
if (!attr) return null;
const value = attr.value;
const nameLower = attr.name.toLowerCase();
// Include source / xi:include href -> open the file.
if (
(el.name === "Include" && nameLower === "source") ||
(el.name === "include" && nameLower === "href")
) {
const resolved =
resolveSource(value, dirname(document.uri.fsPath), searchPathsFor(idx)).path ??
idx.sourceCandidates.find((c) => c.source === value)?.path ??
null;
return resolved
? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
: null;
}
// Asset reference / inheritFrom (filtered by the attribute's ref type).
if (value && !value.startsWith("$")) {
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
if (!targets.length) return null;
if (
this.ws.settings.definitionMode === "project-only" &&
targets.some((t) => t.def.origin === "project")
) {
targets = targets.filter((t) => t.def.origin === "project");
}
const locations: vscode.Location[] = [];
for (const { def } of targets.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
}
return null;
}
}
/**
* Builds a location for an asset definition. For XML sources the location is
* the precise range of the id attribute value (or the element start tag when
* the id value cannot be located), falling back to the recorded line.
*/
async function assetDefLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
): Promise<vscode.Location | null> {
if (def.origin === "manifest") {
const src = def.manifestSource;
if (src?.toUpperCase().startsWith("DATA:")) {
const resolved = resolveSource(src, null, searchPathsFor(idx)).path;
if (resolved) {
// The recorded source file is XML (e.g. SageXml) when available:
// jump to the precise definition inside it, not just the file.
const precise = await locationInDocument(ws, resolved, def.id);
return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0));
}
}
return null;
}
return (
(await locationInDocument(ws, def.file, def.id)) ??
new vscode.Location(
vscode.Uri.file(def.file),
new vscode.Position(Math.max(0, def.line - 1), 0),
)
);
}
/**
* Finds the precise range of an asset definition inside an XML file: the id
* attribute value when present, otherwise the element start tag.
*/
async function locationInDocument(
ws: ModWorkspace,
file: string,
id: string,
): Promise<vscode.Location | null> {
const parsed = await ws.indexer?.readDocument(file);
if (parsed?.parse && parsed.lineMap) {
const el = parsed.parse.elements.find(
(e) =>
e.attrs.some(
(a) => a.name === "id" && a.value.toLowerCase() === id.toLowerCase(),
),
);
if (el) {
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
vscode.Uri.file(file),
new vscode.Range(
toVscodePosition(parsed.lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(parsed.lineMap.positionAt(idAttr.valueEnd)),
),
);
}
return new vscode.Location(
vscode.Uri.file(file),
new vscode.Range(
toVscodePosition(parsed.lineMap.positionAt(el.start)),
toVscodePosition(parsed.lineMap.positionAt(el.startTagEnd)),
),
);
}
}
return null;
}
function toVscodePosition(p: { line: number; character: number }): vscode.Position {
return new vscode.Position(p.line, p.character);
}
// ── Find all references ─────────────────────────────────────────────
export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
async provideReferences(
document: vscode.TextDocument,
position: vscode.Position,
_context: vscode.ReferenceContext,
_token: vscode.CancellationToken,
): Promise<vscode.Location[] | null> {
const text = document.getText();
const offset = document.offsetAt(position);
const doc = parseXml(text);
const el = findElementAt(doc, offset);
if (!el) return null;
const attr = el.attrs.find(
(a) =>
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
(offset >= a.nameStart && offset <= a.nameEnd),
);
if (!attr?.hasValue) return null;
const id = attr.value;
if (!id || id.startsWith("$")) return null;
const locations: vscode.Location[] = [];
const pattern = `["']${escapeRegExp(id)}["']`;
await findTextInWorkspace(
{ pattern, isRegExp: true },
{ include: "**/*.xml", maxResults: 2000 },
(result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => {
if (!result.uri) return;
for (const m of result.matches) {
locations.push(new vscode.Location(result.uri, m.range));
}
},
);
return locations.length ? locations : null;
}
}
// ── Document links (ctrl+click on includes) ─────────────────────────
export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
constructor(private ws: ModWorkspace) {}
async provideDocumentLinks(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> {
const idx = this.ws.index;
if (!idx) return [];
const text = document.getText();
const doc = parseXml(text);
const links: vscode.DocumentLink[] = [];
for (const el of doc.elements) {
if (el.name !== "Include" && el.name !== "include") continue;
const srcAttr = el.attrs.find((a) => a.name === "source" || a.name === "href");
if (!srcAttr?.hasValue) continue;
const target =
resolveSource(
srcAttr.value,
dirname(document.uri.fsPath),
searchPathsFor(idx),
).path ??
idx.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
null;
if (!target) continue;
links.push(
new vscode.DocumentLink(
new vscode.Range(
document.positionAt(srcAttr.valueStart),
document.positionAt(srcAttr.valueEnd),
),
vscode.Uri.file(target),
),
);
}
return links;
}
}
// ── Document symbols (outline) ──────────────────────────────────────
export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider {
async provideDocumentSymbols(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.DocumentSymbol[]> {
const text = document.getText();
const doc = parseXml(text);
const root = doc.root;
if (!root) return [];
const symbols: vscode.DocumentSymbol[] = [];
for (const child of root.children) {
const local = localName(child.name);
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
const idAttr = child.attrs.find((a) => a.name === "id");
const label = idAttr ? `${local} ${idAttr.value}` : local;
const fullRange = new vscode.Range(
document.positionAt(child.start),
document.positionAt(child.end),
);
const selectionRange = new vscode.Range(
document.positionAt(child.start),
document.positionAt(child.startTagEnd),
);
symbols.push(
new vscode.DocumentSymbol(
label,
"",
vscode.SymbolKind.Class,
fullRange,
selectionRange,
),
);
}
for (const child of root.children) {
if (localName(child.name) !== "Defines") continue;
for (const define of child.children) {
if (localName(define.name) !== "Define") continue;
const name = define.attrs.find((a) => a.name === "name")?.value;
if (!name) continue;
const range = new vscode.Range(
document.positionAt(define.start),
document.positionAt(define.end),
);
symbols.push(
new vscode.DocumentSymbol(`$${name}`, "Define", vscode.SymbolKind.Constant, range, range),
);
}
}
return symbols;
}
}
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* `workspace.findTextInFiles` is a stable VS Code API (since 1.66) but is
* missing from the published typings, so we declare the subset we need and
* call it via a safe cast.
*/
interface TextSearchQuery {
pattern: string;
isRegExp?: boolean;
isCaseSensitive?: boolean;
isWordMatch?: boolean;
}
interface TextSearchOptions {
include?: string;
exclude?: string;
maxResults?: number;
}
function findTextInWorkspace(
query: TextSearchQuery,
options: TextSearchOptions,
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
): Promise<void> {
const api = vscode.workspace as unknown as {
findTextInFiles(
query: TextSearchQuery,
options: TextSearchOptions,
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
): Promise<unknown>;
};
return api.findTextInFiles(query, options, callback).then(() => undefined);
}
+109
View File
@@ -0,0 +1,109 @@
import { readdir, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import type { FileWalker, SourceCandidate } from "./types";
/**
* Recursive file list walker with a simple directory-mtime cache. Used to
* enumerate candidate files for Include/@source completion. Directory scans
* outside the workspace (e.g. the SDK) are cached until the directory mtime
* changes.
*/
export class CachedDirectoryWalker implements FileWalker {
private cache = new Map<string, { mtimeMs: number; files: string[] }>();
async listFiles(dir: string): Promise<string[]> {
const key = dir.toLowerCase();
try {
const dirStat = await stat(dir);
const cached = this.cache.get(key);
if (cached && cached.mtimeMs === dirStat.mtimeMs) {
return cached.files;
}
const files: string[] = [];
await this.walk(dir, files);
this.cache.set(key, { mtimeMs: dirStat.mtimeMs, files });
return files;
} catch {
return [];
}
}
private async walk(dir: string, out: string[]): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await this.walk(full, out);
} else if (entry.isFile()) {
out.push(full);
}
}
}
clear(): void {
this.cache.clear();
}
}
/**
* Builds the candidate list for Include/@source completion from a set of
* search directories. For DATA directories only *.xml files are listed; for
* ART/AUDIO directories every file is listed (includes commonly point at
* .w3x/.dds/.mpf files there).
*/
export async function collectSourceCandidates(
walker: FileWalker,
dataDirs: string[],
artDirs: string[],
audioDirs: string[],
projectDataDir: string,
): Promise<SourceCandidate[]> {
const out: SourceCandidate[] = [];
const seen = new Set<string>();
const add = (path: string, baseDir: string, prefix: "DATA" | "ART" | "AUDIO" | null) => {
const rel = relative(baseDir, path).replace(/\\/g, "/");
if (rel.startsWith("..")) return;
const source = prefix ? `${prefix}:${rel}` : rel;
const key = `${prefix ?? ""}:${source.toLowerCase()}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) });
};
for (const dir of dataDirs) {
const files = await walker.listFiles(dir);
for (const f of files) {
if (!f.toLowerCase().endsWith(".xml")) continue;
add(f, dir, "DATA");
}
if (samePath(dir, projectDataDir)) {
// Also offer project-relative paths (what Mod.xml itself uses).
for (const f of files) {
if (f.toLowerCase().endsWith(".xml")) add(f, projectDataDir, null);
}
}
}
for (const dir of artDirs) {
for (const f of await walker.listFiles(dir)) {
add(f, dir, "ART");
}
}
for (const dir of audioDirs) {
for (const f of await walker.listFiles(dir)) {
add(f, dir, "AUDIO");
}
}
return out;
}
function samePath(a: string, b: string): boolean {
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Include path resolution for RA3 Mod XML, ported from the reference
* implementation in check_duplicate_ids.py.
*
* Pure TypeScript: no vscode dependency, so the module can be reused outside
* the extension (e.g. by search/analysis tools).
*/
import { join, resolve, normalize, isAbsolute } from "node:path";
import { statSync } from "node:fs";
export type IncludeKind = "all" | "instance" | "reference";
export type SourcePrefix = "DATA" | "ART" | "AUDIO" | null;
export interface SearchPaths {
DATA: string[];
ART: string[];
AUDIO: string[];
}
export interface ResolveResult {
path: string | null;
prefix: SourcePrefix;
raw: string;
}
const PREFIXES: Exclude<SourcePrefix, null>[] = ["DATA", "ART", "AUDIO"];
/**
* Builds the search path lists used by the SDK compiler:
* (from defaultscript.cs getIncludePaths(), where "." is the SDK root):
* DATA: sdk -> modGranParent -> project/Data -> sdk/Mods -> modParentPath -> sdk/SageXml
* ART: sdk -> modGranParent -> project/Art1 -> project/Art -> sdk/Mods -> modParentPath -> sdk/Art
* AUDIO: sdk -> modGranParent -> project/Audio1 -> project/Audio -> sdk/Mods -> modParentPath -> sdk/Audio
*
* `extra` directories (from user settings) are appended after the defaults
* for their matching prefix.
*/
export function buildSearchPaths(
sdkDir: string,
projectDir: string,
extra?: Partial<Record<"DATA" | "ART" | "AUDIO", string[]>>,
): SearchPaths {
const modParentPath = resolve(projectDir, "..");
const modGranParent = resolve(modParentPath, "..");
return {
DATA: [
sdkDir,
modGranParent,
join(projectDir, "Data"),
join(sdkDir, "Mods"),
modParentPath,
join(sdkDir, "SageXml"),
...(extra?.DATA ?? []),
],
ART: [
sdkDir,
modGranParent,
join(projectDir, "Art1"),
join(projectDir, "Art"),
join(sdkDir, "Mods"),
modParentPath,
join(sdkDir, "Art"),
...(extra?.ART ?? []),
],
AUDIO: [
sdkDir,
modGranParent,
join(projectDir, "Audio1"),
join(projectDir, "Audio"),
join(sdkDir, "Mods"),
modParentPath,
join(sdkDir, "Audio"),
...(extra?.AUDIO ?? []),
],
};
}
function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } {
for (const prefix of PREFIXES) {
if (source.toUpperCase().startsWith(`${prefix}:`)) {
return { prefix, rest: source.slice(prefix.length + 1).replace(/^[/\\]+/, "") };
}
}
return { prefix: null, rest: source.replace(/^[/\\]+/, "") };
}
/**
* Resolves an Include/@source (or xi:include/@href) to an absolute file path,
* or null when not found.
*
* - DATA:/ART:/AUDIO: prefixes are resolved against the corresponding search
* paths, in order.
* - ART: paths without a directory separator also try the 2-letter prefix
* subdirectory (e.g. JUAntiShip -> ju/JUAntiShip).
* - Paths without a prefix are resolved relative to the including file.
*/
export function resolveSource(
source: string,
currentDir: string | null,
searchPaths: SearchPaths,
): ResolveResult {
const raw = source.trim().replace(/\\/g, "/");
const { prefix, rest } = splitPrefix(raw);
if (prefix) {
const bases = searchPaths[prefix] ?? [];
const direct = findInBases(rest, bases);
if (direct) return { path: direct, prefix, raw };
if (prefix === "ART" && !rest.includes("/")) {
const two = rest.slice(0, 2).toLowerCase();
const prefixed = findInBases(`${two}/${rest}`, bases);
if (prefixed) return { path: prefixed, prefix, raw };
}
return { path: null, prefix, raw };
}
if (currentDir && isAbsolute(rest)) {
return { path: fileExists(rest) ? rest : null, prefix: null, raw };
}
if (currentDir) {
const candidate = resolve(currentDir, rest);
return { path: fileExists(candidate) ? candidate : null, prefix: null, raw };
}
return { path: null, prefix: null, raw };
}
function findInBases(relPath: string, bases: string[]): string | null {
for (const base of bases) {
const candidate = normalize(resolve(base, relPath));
if (fileExists(candidate)) return candidate;
}
return null;
}
function fileExists(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/**
* For `<Include type="reference" source="DATA:static.xml">`, returns the
* compiled manifest file that backs the placeholder, when present in one of
* the builtmods directories. The placeholder file name maps to
* `<name>.manifest` (e.g. static.xml -> static.manifest).
*/
export function manifestPathForReference(
source: string,
builtmodsDirs: string[],
): string | null {
const base = basenameWithoutExt(stripPrefix(source).replace(/\\/g, "/"));
if (!base) return null;
for (const dir of builtmodsDirs) {
const candidate = join(dir, `${base}.manifest`);
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
// continue
}
}
return null;
}
function basenameWithoutExt(path: string): string {
const idx = path.lastIndexOf("/");
const file = idx >= 0 ? path.slice(idx + 1) : path;
const dot = file.lastIndexOf(".");
return dot > 0 ? file.slice(0, dot) : file;
}
function stripPrefix(source: string): string {
const idx = source.indexOf(":");
return idx >= 0 ? source.slice(idx + 1) : source;
}
+593
View File
@@ -0,0 +1,593 @@
/**
* Workspace indexer for RA3 Mod XML.
*
* Walks the include graph from Data/Mod.xml (static stream) and
* Data/additionalmaps/mapmetadata_*.xml (global streams), collects asset
* definitions, `$DEFINE` constants, resolved `reference` includes (parsed
* from compiled .manifest files) and a file-name index used for
* Include/@source completion.
*
* Pure TypeScript (no vscode dependency) so the indexing core can be reused
* outside the extension.
*/
import { readFile, readdir, stat } from "node:fs/promises";
import { basename, dirname, extname, join, resolve } from "node:path";
import { LineMap, parseXml, type XmlDocument, type XmlElement } from "../language/xmlParser";
import {
buildSearchPaths,
manifestPathForReference,
resolveSource,
type SearchPaths,
} from "./includeResolver";
import {
deriveAssetId,
deriveAssetType,
parseManifest,
type ManifestInfo,
} from "./manifestParser";
import { canonicalTypeName } from "../model/schemaModel";
import { collectSourceCandidates } from "./fileScanner";
import type {
AssetDef,
DefineDef,
IndexOptions,
IndexedFile,
ModIndex,
ParsedFile,
SourceCandidate,
StreamInfo,
} from "./types";
const MAX_DEPTH = 300;
/** Files above this size are never parsed (safety against binary blobs). */
const MAX_PARSE_BYTES = 4 * 1024 * 1024;
/** Only these extensions are treated as XML documents. */
const XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
function normKey(path: string): string {
return resolve(path).toLowerCase();
}
/**
* LRU cache for parsed documents. The parse trees of huge mods can be
* memory-heavy, so only a bounded number of recent documents is retained;
* evicted entries are re-read from disk on demand.
*/
export class DocumentCache {
private map = new Map<string, ParsedFile>();
constructor(private capacity = 64) {}
get(path: string): ParsedFile | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
this.map.delete(key);
this.map.set(key, parsed);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
}
clear(): void {
this.map.clear();
}
}
export class ModIndexer {
private searchPaths: SearchPaths;
private docs = new DocumentCache();
private assets = new Map<string, Map<string, AssetDef[]>>();
private assetsById = new Map<string, AssetDef[]>();
private defines = new Map<string, DefineDef[]>();
private files = new Map<string, IndexedFile>();
private streams: StreamInfo[] = [];
private manifests = new Map<string, ManifestInfo>();
private sourceCandidates: SourceCandidate[] = [];
private diagnostics: ModIndex["diagnostics"] = [];
private visitedAll = new Set<string>();
private visitedInstance = new Set<string>();
private manifestAssetKeys = new Set<string>();
constructor(private opts: IndexOptions) {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
DATA: opts.additionalDataSearchPaths,
});
}
/** Re-reads and caches a document; null when unreadable. */
async readDocument(path: string): Promise<ParsedFile | null> {
const hit = this.docs.get(path);
if (hit) return hit;
try {
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]);
if (st.size > MAX_PARSE_BYTES) {
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
const parsed: ParsedFile = { file, parse: null, lineMap: null };
this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), file);
return parsed;
}
const parse = parseXml(text);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse,
lineMap: new LineMap(text),
};
this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file);
return parsed;
} catch {
const parsed: ParsedFile = {
file: { path: resolve(path), stat: null },
parse: null,
lineMap: null,
};
this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file);
return parsed;
}
}
/** Returns the cached parse if present (does not read from disk). */
cachedDocument(path: string): ParsedFile | undefined {
return this.docs.get(path);
}
async build(): Promise<ModIndex> {
const start = Date.now();
const projectData = await findCaseInsensitiveDir(join(this.opts.projectDir, "Data"));
const additionalMaps = projectData
? await findCaseInsensitiveDir(join(projectData, "additionalmaps"))
: null;
// ── Streams ──
const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
if (staticEntry) {
const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() };
this.streams.push(stream);
await this.walk(staticEntry, "all", stream, 0);
}
if (additionalMaps) {
let entries: string[] = [];
try {
entries = await readdir(additionalMaps);
} catch {
entries = [];
}
const metadataFiles = entries
.filter((f) => /^mapmetadata_.*\.xml$/i.test(f))
.sort();
for (const f of metadataFiles) {
const entry = join(additionalMaps, f);
const stream: StreamInfo = {
name: `global:${basename(f, ".xml")}`,
entry,
files: new Set(),
};
this.streams.push(stream);
await this.walk(entry, "all", stream, 0);
}
}
// ── Source completion candidates ──
const dataDirs = [
projectData ?? join(this.opts.projectDir, "Data"),
join(this.opts.sdkDir, "SageXml"),
...this.opts.additionalDataSearchPaths,
];
if (!this.opts.indexSageXml) {
const sage = join(this.opts.sdkDir, "SageXml");
const idx = dataDirs.findIndex((d) => normKey(d) === normKey(sage));
if (idx >= 0) dataDirs.splice(idx, 1);
}
const artDirs = [
join(this.opts.projectDir, "Art1"),
join(this.opts.projectDir, "Art"),
join(this.opts.sdkDir, "Art"),
];
const audioDirs = [
join(this.opts.projectDir, "Audio1"),
join(this.opts.projectDir, "Audio"),
join(this.opts.sdkDir, "Audio"),
];
this.sourceCandidates = await collectSourceCandidates(
this.opts.walker,
dataDirs,
artDirs,
audioDirs,
projectData ?? join(this.opts.projectDir, "Data"),
);
// The SDK root itself is the first DATA: search base (static.xml,
// global.xml, audio.xml placeholders) but only its shallow XML files are
// relevant. These candidates take precedence over same-named files found
// deeper in the search paths (e.g. SageXml/Static.xml).
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
(f) => f.toLowerCase().endsWith(".xml"),
);
const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({
source: `DATA:${f}`,
path: resolve(this.opts.sdkDir, f),
prefix: "DATA",
baseDir: resolve(this.opts.sdkDir),
}));
this.sourceCandidates = dedupeSourceCandidates([
...sdkRootCandidates,
...this.sourceCandidates,
]);
const manifestAssetCount = [...this.manifests.values()].reduce(
(sum, m) => sum + m.assets.length,
0,
);
return {
projectDir: resolve(this.opts.projectDir),
sdkDir: resolve(this.opts.sdkDir),
assets: this.assets,
assetsById: this.assetsById,
defines: this.defines,
files: this.files,
streams: this.streams,
manifests: this.manifests,
sourceCandidates: this.sourceCandidates,
diagnostics: this.diagnostics,
stats: {
projectDir: resolve(this.opts.projectDir),
sdkDir: resolve(this.opts.sdkDir),
indexedFiles: this.files.size,
parsedFiles: [...this.files.values()].filter(
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
).length,
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
defineCount: this.defines.size,
manifestFiles: this.manifests.size,
manifestAssetCount,
streams: this.streams.length,
sourceCandidates: this.sourceCandidates.length,
elapsedMs: Date.now() - start,
},
};
}
// ── Include walk ──────────────────────────────────────────────────
private async walk(
path: string,
mode: "all" | "instance",
stream: StreamInfo,
depth: number,
): Promise<void> {
const key = normKey(path);
if (depth > MAX_DEPTH) {
this.diagnostics.push({
file: path,
line: 0,
message: "Include depth exceeded - possible include cycle",
severity: "warning",
code: "include-cycle",
});
return;
}
if (mode === "all") {
if (this.visitedAll.has(key)) return;
this.visitedAll.add(key);
} else {
if (this.visitedInstance.has(key)) return;
this.visitedInstance.add(key);
if (this.visitedAll.has(key)) return;
}
stream.files.add(key);
// Binary assets (w3x/dds/...) are referenced but never parsed.
if (!isXmlPath(path)) return;
const parsed = await this.readDocument(path);
if (!parsed?.parse?.root) return;
const root = parsed.parse.root;
for (const child of root.children) {
const local = localName(child.name);
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
if (local === "include") {
await this.handleXiInclude(child, parsed, stream, depth);
continue;
}
const idAttr = child.attrs.find((a) => a.name === "id");
if (idAttr) {
this.addAsset({
type: local,
id: idAttr.value,
file: parsed.file.path,
line: lineOf(parsed, idAttr.valueStart),
origin: this.originOf(parsed.file.path),
stream: stream.name,
viaInstance: mode === "instance",
});
}
}
for (const child of root.children) {
if (localName(child.name) !== "Defines") continue;
for (const define of child.children) {
if (localName(define.name) !== "Define") continue;
const name = define.attrs.find((a) => a.name === "name")?.value;
const value = define.attrs.find((a) => a.name === "value")?.value;
if (!name) continue;
const entry: DefineDef = {
name,
value: value ?? "",
file: parsed.file.path,
line: lineOf(parsed, define.start),
origin: this.originOf(parsed.file.path),
};
const arr = this.defines.get(name.toLowerCase());
if (arr) arr.push(entry);
else this.defines.set(name.toLowerCase(), [entry]);
}
}
const includesElem = root.children.find((c) => localName(c.name) === "Includes");
if (includesElem) {
for (const inc of includesElem.children) {
if (localName(inc.name) !== "Include") continue;
const type = inc.attrs.find((a) => a.name === "type")?.value;
const source = inc.attrs.find((a) => a.name === "source")?.value;
if (!source) continue;
const resolved = resolveSource(source, dirname(parsed.file.path), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file: parsed.file.path,
line: lineOf(parsed, inc.start),
message: `Include target not found: ${source}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
if (type === "all" || type === "instance") {
await this.walk(resolved.path, type === "all" ? "all" : "instance", stream, depth + 1);
} else if (type === "reference") {
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded && isXmlPath(resolved.path)) {
// The manifest could not be parsed (missing/invalid): fall back
// to the placeholder XML so its content is still available.
await this.walk(resolved.path, "instance", stream, depth + 1);
}
} else if (isXmlPath(resolved.path)) {
// reference to a real XML file: treat its assets as available
await this.walk(resolved.path, "instance", stream, depth + 1);
}
}
}
}
// Nested <xi:include> anywhere in the tree (not just under the root):
// the target content is inlined into the parent element. We make the
// target file available and surface missing targets instead of ignoring
// them silently.
for (const el of parsed.parse.elements) {
if (localName(el.name) !== "include") continue;
if (el.parent === root) continue; // already handled in the loop above
const href = el.attrs.find((a) => a.name === "href")?.value;
if (!href) continue;
const resolved = resolveSource(href, dirname(parsed.file.path), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file: parsed.file.path,
line: lineOf(parsed, el.start),
message: `xi:include target not found: ${href}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
stream.files.add(normKey(resolved.path));
if (isXmlPath(resolved.path)) {
await this.walk(resolved.path, "all", stream, depth + 1);
}
}
}
private async handleXiInclude(
xi: XmlElement,
parent: ParsedFile,
stream: StreamInfo,
depth: number,
): Promise<void> {
const href = xi.attrs.find((a) => a.name === "href")?.value;
if (!href) return;
const resolved = resolveSource(href, dirname(parent.file.path), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file: parent.file.path,
line: lineOf(parent, xi.start),
message: `xi:include target not found: ${href}`,
severity: "warning",
code: "include-not-found",
});
return;
}
if (!isXmlPath(resolved.path)) return;
const target = await this.readDocument(resolved.path);
if (!target?.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
let candidates: XmlElement[];
if (xpointer) {
const container = findXPointerContainer(target.parse, xpointer);
candidates = container ? container.children : [];
} else {
candidates = target.parse.root.children;
}
for (const el of candidates) {
const local = localName(el.name);
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr) {
this.addAsset({
type: local,
id: idAttr.value,
file: target.file.path,
line: lineOf(target, idAttr.valueStart),
origin: this.originOf(target.file.path),
stream: stream.name,
});
}
}
stream.files.add(normKey(target.file.path));
await this.walk(target.file.path, "all", stream, depth + 1);
}
// ── Manifest loading ──────────────────────────────────────────────
/** Returns true when the manifest was parsed successfully. */
private async loadManifest(path: string, streamName: string): Promise<boolean> {
const key = normKey(path);
let info = this.manifests.get(key);
if (!info) {
try {
const data = await readFile(path);
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
info = parseManifest(buffer);
} catch {
this.diagnostics.push({
file: path,
line: 0,
message: "Manifest file could not be read",
severity: "warning",
code: "manifest-read-error",
});
return false;
}
this.manifests.set(key, info);
}
if (info.error) return false;
for (const asset of info.assets) {
if (!asset.name) continue;
const id = deriveAssetId(asset.name);
const assetKey = `${asset.typeId}:${id.toLowerCase()}`;
if (this.manifestAssetKeys.has(assetKey)) continue;
this.manifestAssetKeys.add(assetKey);
this.addAsset({
type:
canonicalTypeName(deriveAssetType(asset.typeName, asset.name)) ??
`#${asset.typeId.toString(16)}`,
id,
file: path,
line: 0,
origin: "manifest",
stream: streamName,
manifest: path,
manifestSource: asset.sourceFileName,
});
}
return true;
}
// ── Helpers ───────────────────────────────────────────────────────
private originOf(path: string): "project" | "sdk" {
const p = resolve(path).toLowerCase();
const project = resolve(this.opts.projectDir).toLowerCase();
const sdk = resolve(this.opts.sdkDir).toLowerCase();
if (p.startsWith(project + "\\")) return "project";
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project";
}
private addAsset(def: AssetDef): void {
// Keep the original case: type names are matched against the XSD model.
const typeKey = def.type;
const idKey = def.id.toLowerCase();
let byId = this.assets.get(typeKey);
if (!byId) {
byId = new Map();
this.assets.set(typeKey, byId);
}
const arr = byId.get(idKey);
if (arr) {
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.assetsById.get(idKey);
if (all) {
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
all.push(def);
} else {
this.assetsById.set(idKey, [def]);
}
}
}
// ── Module-level helpers ─────────────────────────────────────────────
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function lineOf(parsed: ParsedFile, offset: number): number {
if (!parsed.lineMap) return 0;
return parsed.lineMap.positionAt(offset).line + 1;
}
function isXmlPath(path: string): boolean {
const ext = extname(path).toLowerCase();
return XML_EXTENSIONS.has(ext);
}
async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
const parent = dirname(dir);
const wanted = basename(dir);
try {
const entries = await readdir(parent, { withFileTypes: true });
const hit = entries.find(
(e) => e.isDirectory() && e.name.toLowerCase() === wanted.toLowerCase(),
);
return hit ? join(parent, hit.name) : null;
} catch {
return null;
}
}
function findXPointerContainer(doc: XmlDocument, xpointer: string): XmlElement | null {
// Supports the form used by the mods:
// xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
const m = /xpointer\(\/\w+:(\w+)\/child::\*\)/.exec(xpointer);
if (!m) return null;
const name = m[1];
return doc.elements.find((el) => localName(el.name) === name) ?? null;
}
/** Keeps the first candidate for each case-insensitive source string. */
function dedupeSourceCandidates(candidates: SourceCandidate[]): SourceCandidate[] {
const seen = new Set<string>();
const out: SourceCandidate[] = [];
for (const c of candidates) {
const key = c.source.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(c);
}
return out;
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Parser for SAGE `.manifest` files, ported from OpenSAGE
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
*
* The manifest is a binary index produced by BinaryAssetBuilder: every asset
* compiled into a stream is listed with hashed type/instance ids, an offset
* into the asset-name string buffer, and an optional source file name.
* Parsing it lets the extension treat `reference` includes (static.xml,
* global.xml, audio.xml) as real, searchable asset pools.
*/
import { assetTypeNameFromHash } from "../model/schemaModel";
export interface ManifestAsset {
typeId: number;
typeName: string | null;
name: string;
sourceFileName: string;
}
export interface ManifestReferenceEntry {
referenceType: number;
path: string;
}
export interface ManifestInfo {
version: number;
isBigEndian: boolean;
isLinked: boolean;
assetCount: number;
assets: ManifestAsset[];
manifestReferences: ManifestReferenceEntry[];
/** Present when the file could not be parsed. */
error?: string;
}
/**
* Derives an asset type from its manifest name. BAB stores manifest assets
* as "TypeName:Id" (e.g. "PlayerTemplate:Allies"), so the part before the
* first colon is the type even when the TypeId hash is unknown to the
* bundled AssetType table. Asset ids cannot contain ":" (InstanceId pattern),
* so the split is unambiguous.
*/
export function deriveAssetType(typeName: string | null, assetName: string): string | null {
if (typeName) return typeName;
const idx = assetName.indexOf(":");
if (idx <= 0) return null;
const prefix = assetName.slice(0, idx);
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix) ? prefix : null;
}
/**
* Extracts the referenceable asset id from a manifest name. BAB stores
* manifest names as "TypeName:InstanceId" where the instance id may itself
* carry a subtype prefix for art assets, e.g.
* W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN
* The referenceable id is the last colon-separated segment (asset ids cannot
* contain ":" per the InstanceId pattern).
*/
export function deriveAssetId(assetName: string): string {
const idx = assetName.lastIndexOf(":");
return idx >= 0 ? assetName.slice(idx + 1) : assetName;
}
class Cursor {
private pos = 0;
constructor(private buf: Uint8Array) {}
get position(): number {
return this.pos;
}
byte(): number {
if (this.pos >= this.buf.length) throw new Error("Unexpected end of file");
return this.buf[this.pos++];
}
uint16(): number {
const a = this.byte();
const b = this.byte();
return a | (b << 8);
}
uint32(): number {
const a = this.byte();
const b = this.byte();
const c = this.byte();
const d = this.byte();
return ((a | (b << 8) | (c << 16) | (d << 24)) >>> 0);
}
skip(n: number): void {
this.pos += n;
if (this.pos > this.buf.length) throw new Error("Unexpected end of file");
}
nullTerminatedString(): string {
const start = this.pos;
while (this.pos < this.buf.length && this.buf[this.pos] !== 0) {
this.pos++;
}
const end = this.pos;
if (this.pos < this.buf.length) this.pos++; // consume NUL
return decodeAscii(this.buf, start, end);
}
readNameBuffer(endPosition: number): Map<number, string> {
const names = new Map<number, string>();
let nameOffset = 0;
while (this.pos < endPosition) {
const start = this.pos;
names.set(nameOffset, this.nullTerminatedString());
nameOffset += this.pos - start;
}
return names;
}
}
function decodeAscii(buf: Uint8Array, start: number, end: number): string {
// Asset ids are ASCII; anything else decodes with replacement chars.
let out = "";
for (let i = start; i < end; i++) {
out += String.fromCharCode(buf[i]);
}
return out;
}
export function parseManifest(buffer: Uint8Array): ManifestInfo {
try {
return parseManifestInner(buffer);
} catch (err) {
return {
version: 0,
isBigEndian: false,
isLinked: false,
assetCount: 0,
assets: [],
manifestReferences: [],
error: err instanceof Error ? err.message : String(err),
};
}
}
function parseManifestInner(buffer: Uint8Array): ManifestInfo {
const cursor = new Cursor(buffer);
const testValue = cursor.uint32();
let version: number;
let isBigEndian: boolean;
let isLinked: boolean;
if (testValue === 0) {
version = cursor.uint16();
if (version !== 7) throw new Error(`Unsupported manifest version ${version}`);
isBigEndian = readBooleanChecked(cursor);
isLinked = readBooleanChecked(cursor);
} else {
cursor.skip(-4);
isBigEndian = readBooleanChecked(cursor);
isLinked = readBooleanChecked(cursor);
version = cursor.uint16();
if (version !== 5 && version !== 6) {
throw new Error(`Unsupported manifest version ${version}`);
}
}
const streamChecksum = cursor.uint32();
const allTypesHash = cursor.uint32();
const assetCount = cursor.uint32();
const totalInstanceDataSize = cursor.uint32();
const maxInstanceChunkSize = cursor.uint32();
const maxRelocationChunkSize = cursor.uint32();
const maxImportsChunkSize = cursor.uint32();
const assetReferenceBufferSize = cursor.uint32();
const referencedManifestNameBufferSize = cursor.uint32();
const assetNameBufferSize = cursor.uint32();
const sourceFileNameBufferSize = cursor.uint32();
void streamChecksum;
void allTypesHash;
void totalInstanceDataSize;
void maxInstanceChunkSize;
void maxRelocationChunkSize;
void maxImportsChunkSize;
interface RawEntry {
typeId: number;
nameOffset: number;
sourceFileNameOffset: number;
}
const rawEntries: RawEntry[] = [];
for (let i = 0; i < assetCount; i++) {
const typeId = cursor.uint32();
cursor.uint32(); // instanceId
cursor.uint32(); // typeHash
cursor.uint32(); // instanceHash
cursor.uint32(); // assetReferenceOffset
cursor.uint32(); // assetReferenceCount
const nameOffset = cursor.uint32();
const sourceFileNameOffset = cursor.uint32();
cursor.uint32(); // instanceDataSize
cursor.uint32(); // relocationDataSize
cursor.uint32(); // importsDataSize
if (version >= 6) {
cursor.byte(); // isTokenized
cursor.skip(3);
}
rawEntries.push({
typeId,
nameOffset,
sourceFileNameOffset,
});
}
// Asset references buffer (not needed for completions).
cursor.skip(assetReferenceBufferSize);
// Referenced manifest names.
const manifestRefsEnd = cursor.position + referencedManifestNameBufferSize;
const manifestReferences: ManifestReferenceEntry[] = [];
while (cursor.position < manifestRefsEnd) {
const referenceType = cursor.byte();
const path = cursor.nullTerminatedString();
manifestReferences.push({ referenceType, path });
}
// Asset names.
const assetNamesEnd = cursor.position + assetNameBufferSize;
const assetNames = cursor.readNameBuffer(assetNamesEnd);
// Source file names.
const sourceNamesEnd = cursor.position + sourceFileNameBufferSize;
const sourceNames = cursor.readNameBuffer(sourceNamesEnd);
const assets: ManifestAsset[] = rawEntries.map((entry) => ({
typeId: entry.typeId,
typeName: assetTypeNameFromHash(entry.typeId) ?? null,
name: assetNames.get(entry.nameOffset) ?? "",
sourceFileName: sourceNames.get(entry.sourceFileNameOffset) ?? "",
}));
return {
version,
isBigEndian,
isLinked,
assetCount,
assets,
manifestReferences,
};
}
function readBooleanChecked(cursor: Cursor): boolean {
const value = cursor.byte();
if (value === 0) return false;
if (value === 1) return true;
throw new Error(`Invalid boolean byte ${value}`);
}
+91
View File
@@ -0,0 +1,91 @@
import {
attributesOfType,
elementTypeName,
isAssignableTo,
} from "../model/schemaModel";
import type { AssetDef, ModIndex } from "./types";
export interface ReferenceTarget {
def: AssetDef;
score: number;
}
/**
* True when an attribute is a typed reference: either the instance
* inheritance attribute `inheritFrom`, or an XSD attribute whose simple type
* carries an `xas:refType`. Enumerations and file paths (e.g.
* `Include/@type`, `Include/@source`) are not references.
*/
export function isReferenceAttribute(elementName: string, attrName: string): boolean {
return isReferenceAttributeOfType(elementTypeName(elementName), attrName);
}
/** Same check, but driven by a resolved XSD type name. */
export function isReferenceAttributeOfType(
typeName: string | null,
attrName: string,
): boolean {
if (attrName.toLowerCase() === "inheritfrom") return true;
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
return attr != null && (attr.refType != null || attr.isRef);
}
/**
* Resolves the definitions a reference attribute value should point to.
*
* The result is strictly filtered by the attribute's reference type (from the
* XSD model) so that an id shared by several asset types only resolves to the
* matching definition (e.g. `Weapon="X"` jumps to the WeaponTemplate with
* id "X", never to a GameObject that happens to share the id).
*
* Returns [] when the attribute is not a typed reference or nothing matches.
*/
export function resolveReferenceTargets(
idx: ModIndex,
elementType: string,
attrName: string,
id: string,
): ReferenceTarget[] {
return resolveReferenceTargetsForType(
idx,
elementTypeName(elementType),
attrName,
id,
);
}
/** Same resolution, driven by a resolved XSD type name. */
export function resolveReferenceTargetsForType(
idx: ModIndex,
typeName: string | null,
attrName: string,
id: string,
): ReferenceTarget[] {
const defs = idx.assetsById.get(id.toLowerCase());
if (!defs?.length) return [];
const nameLower = attrName.toLowerCase();
let refType: string | null = null;
let selfType: string | null = null;
if (nameLower === "inheritfrom") {
selfType = typeName;
} else {
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
if (!attr || (!attr.refType && !attr.isRef)) return [];
refType = attr.refType;
}
const targets: ReferenceTarget[] = [];
for (const def of defs) {
if (refType && !isAssignableTo(def.type, refType)) continue;
if (selfType && !isAssignableTo(def.type, selfType)) continue;
let score = 3;
if (def.origin === "project") score = 0;
else if (def.origin === "sdk") score = 1;
else score = 2;
targets.push({ def, score });
}
targets.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
return targets;
}
+125
View File
@@ -0,0 +1,125 @@
import type { XmlDocument } from "../language/xmlParser";
import type { ManifestInfo } from "./manifestParser";
import type { LineMap } from "../language/xmlParser";
export type AssetOrigin = "project" | "sdk" | "manifest";
export interface AssetDef {
type: string;
id: string;
/** Absolute path of the defining file (for manifests: the manifest path). */
file: string;
/** 1-based line of the id attribute (or 0 when unknown, e.g. manifests). */
line: number;
origin: AssetOrigin;
/** "static" or "global:<name>" when the asset comes from a stream. */
stream?: string;
/** Set for assets that are only reachable through `instance` includes. */
viaInstance?: boolean;
/** Manifest path for origin === "manifest". */
manifest?: string;
/** Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml"). */
manifestSource?: string;
}
export interface DefineDef {
name: string;
value: string;
file: string;
line: number;
origin: AssetOrigin;
}
export interface IndexedFile {
path: string;
stat: { mtimeMs: number; size: number } | null;
}
export interface StreamInfo {
name: string;
entry: string;
files: Set<string>;
}
export interface SourceCandidate {
/** Suggested value for Include/@source. */
source: string;
/** Absolute path the candidate resolves to. */
path: string;
/** "DATA" | "ART" | "AUDIO" | null (relative). */
prefix: "DATA" | "ART" | "AUDIO" | null;
/** Directory that acts as the root of the relative path. */
baseDir: string;
}
export interface IndexerDiagnostic {
file: string;
line: number;
message: string;
severity: "warning" | "error" | "information";
code: string;
}
export interface IndexStats {
projectDir: string;
sdkDir: string;
indexedFiles: number;
parsedFiles: number;
assetCount: number;
defineCount: number;
manifestFiles: number;
manifestAssetCount: number;
streams: number;
sourceCandidates: number;
elapsedMs: number;
}
export interface ModIndex {
projectDir: string;
sdkDir: string;
/** type -> id -> definitions (project + sdk + manifest, deduplicated). */
assets: Map<string, Map<string, AssetDef[]>>;
/** id -> definitions across all types. */
assetsById: Map<string, AssetDef[]>;
/** `$NAME` -> definitions. */
defines: Map<string, DefineDef[]>;
/** absolute path -> file record (only files touched by the include walk). */
files: Map<string, IndexedFile>;
streams: StreamInfo[];
manifests: Map<string, ManifestInfo>;
/** Files suggested for Include/@source completion. */
sourceCandidates: SourceCandidate[];
/** Problems found while indexing (unresolved includes, cycles, ...). */
diagnostics: IndexerDiagnostic[];
stats: IndexStats;
}
export interface IndexOptions {
projectDir: string;
sdkDir: string;
builtmodsDirs: string[];
indexSageXml: boolean;
additionalDataSearchPaths: string[];
/** Directory walker used to enumerate files for source completion. */
walker: FileWalker;
}
export interface FileWalker {
/** Recursively lists files under a directory. Cached by the caller. */
listFiles(dir: string): Promise<string[]>;
}
export interface ParseCache {
get(path: string): IndexedFile | undefined;
set(file: IndexedFile): void;
clear(): void;
/** Removes the cached entry for a path. */
invalidate(path: string): void;
}
/** A parsed file plus its line map, produced on demand. */
export interface ParsedFile {
file: IndexedFile;
parse: XmlDocument | null;
lineMap: LineMap | null;
}
+130
View File
@@ -0,0 +1,130 @@
import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
export type ContextKind =
| "element-name"
| "attribute-name"
| "attribute-value"
| "content"
| "none";
export interface CompletionContext {
kind: ContextKind;
/** The element whose start tag the cursor is in (or whose content). */
element: XmlElement | null;
/** Set when the cursor is inside a closing tag name ("</..."). */
closing: boolean;
/** The attribute being edited (attribute-value context). */
attr: XmlAttribute | null;
/** Text between the opening quote and the cursor. */
valuePrefix: string;
/** Names of attributes already present on the element. */
existingAttrs: string[];
}
export function analyzeContext(
doc: XmlDocument,
text: string,
offset: number,
): CompletionContext {
// Find the innermost element whose span contains the offset.
let container: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
if (!container || el.depth > container.depth) container = el;
}
}
if (!container) return empty("none");
// Inside the start tag of the element.
if (offset >= container.start && offset <= container.startTagEnd) {
return analyzeStartTag(container, text, offset);
}
// Otherwise the cursor is in element content.
return {
kind: "content",
element: container,
closing: false,
attr: null,
valuePrefix: "",
existingAttrs: [],
};
}
function analyzeStartTag(
el: XmlElement,
text: string,
offset: number,
): CompletionContext {
const closing = text.startsWith("</", el.start);
const nameStart = el.start + (closing ? 2 : 1);
const nameEnd = nameStart + el.name.length;
const existingAttrs = el.attrs.map((a) => a.name);
if (offset <= nameEnd) {
return {
kind: "element-name",
element: el,
closing,
attr: null,
valuePrefix: "",
existingAttrs,
};
}
// Inside an attribute value?
for (const attr of el.attrs) {
if (attr.hasValue && offset >= attr.quoteStart && offset <= attr.quoteEnd) {
const start = attr.valueStart;
const prefix = offset > start ? text.slice(start, offset) : "";
return {
kind: "attribute-value",
element: el,
closing: false,
attr,
valuePrefix: prefix,
existingAttrs,
};
}
}
// Right after "=" (no quotes yet) or between attributes.
const before = text.slice(el.start, offset);
const trimmed = before.replace(/\s+$/, "");
if (trimmed.endsWith("=")) {
return {
kind: "attribute-value",
element: el,
closing: false,
attr: lastAttrOf(el),
valuePrefix: "",
existingAttrs,
};
}
return {
kind: "attribute-name",
element: el,
closing: false,
attr: null,
valuePrefix: "",
existingAttrs,
};
}
function lastAttrOf(el: XmlElement): XmlAttribute | null {
return el.attrs.length ? el.attrs[el.attrs.length - 1] : null;
}
function empty(kind: ContextKind): CompletionContext {
return {
kind,
element: null,
closing: false,
attr: null,
valuePrefix: "",
existingAttrs: [],
};
}
+16
View File
@@ -0,0 +1,16 @@
import { childTypeOf, elementTypeName } from "../model/schemaModel";
import type { XmlElement } from "./xmlParser";
/**
* Resolves the XSD type of an element from the parsed document tree by
* walking up to the root and applying context-aware child lookups at every
* level. Falls back to the global element->type map when the parent chain
* does not declare the child.
*/
export function resolveElementType(el: XmlElement): string | null {
if (!el.parent) {
return elementTypeName(el.name);
}
const parentType = resolveElementType(el.parent);
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
}
+438
View File
@@ -0,0 +1,438 @@
/**
* Lightweight XML parser with source offsets.
*
* The extension needs exact positions of tags, attributes and values for
* completions, hover, navigation and diagnostics. fast-xml-parser does not
* provide offsets, so we use this small purpose-built parser instead. It is
* deliberately tolerant: malformed documents still produce a partial tree
* plus a list of errors, so completion keeps working while typing.
*/
export interface XmlAttribute {
name: string;
value: string;
/** Offset of the first character of the name. */
nameStart: number;
/** Offset one past the last character of the name. */
nameEnd: number;
/** Offset of the first value character (after the opening quote). */
valueStart: number;
/** Offset one past the last value character (before the closing quote). */
valueEnd: number;
/** Offset of the opening quote. */
quoteStart: number;
/** Offset one past the closing quote. */
quoteEnd: number;
hasValue: boolean;
/** True when the value is delimited with double quotes. */
doubleQuoted: boolean;
}
export interface XmlElement {
name: string;
attrs: XmlAttribute[];
children: XmlElement[];
parent: XmlElement | null;
/** Offset of "<". */
start: number;
/** Offset one past the ">" of the start tag. */
startTagEnd: number;
/** Offset one past the end of the whole element (closing tag or "/>"). */
end: number;
selfClosing: boolean;
/** Offset of "</" of the closing tag, or -1 when self-closing. */
closeTagStart: number;
depth: number;
}
export interface XmlParseError {
message: string;
offset: number;
line: number;
character: number;
}
export interface XmlDocument {
root: XmlElement | null;
/** All elements in document order (including the root). */
elements: XmlElement[];
errors: XmlParseError[];
/** Offset one past "?>" of the XML declaration, or 0. */
declarationEnd: number;
}
export interface Position {
line: number;
character: number;
}
/** Precomputes line start offsets for offset <-> position conversion. */
export class LineMap {
private lineStarts: number[] = [0];
constructor(text: string) {
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) {
this.lineStarts.push(i + 1);
}
}
}
positionAt(offset: number): Position {
let lo = 0;
let hi = this.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.lineStarts[mid] <= offset) {
lo = mid;
} else {
hi = mid - 1;
}
}
return { line: lo, character: offset - this.lineStarts[lo] };
}
lineStart(line: number): number {
if (line < 0) return 0;
if (line >= this.lineStarts.length) return this.lineStarts[this.lineStarts.length - 1];
return this.lineStarts[line];
}
}
interface RawTag {
name: string;
selfClosing: boolean;
start: number;
contentStart: number;
contentEnd: number;
end: number;
attrs: XmlAttribute[];
}
const NAME_RE = /[A-Za-z_][\w:.-]*/y;
function parseTag(content: string, contentStart: number): RawTag {
const base = contentStart;
let j = 0;
while (j < content.length && /\s/.test(content[j])) {
j++;
}
let name: string;
NAME_RE.lastIndex = j;
const m = NAME_RE.exec(content);
if (!m) {
name = "";
} else {
name = m[0];
}
const attrs: XmlAttribute[] = [];
let i = m ? m.index + name.length : j;
let selfClosing = false;
while (i < content.length) {
// skip whitespace
while (i < content.length && /\s/.test(content[i])) {
i++;
}
if (i >= content.length) break;
const c = content[i];
// The tag content excludes the terminating ">", so a bare "/" (outside
// quotes) can only be the self-closing marker: "<name .../>".
if (c === "/") {
selfClosing = true;
i++;
break;
}
if (c === ">") {
i += 1;
break;
}
// attribute name
const attrNameStart = i;
while (i < content.length && !/[\s=/>]/.test(content[i])) {
i++;
}
const attrName = content.slice(attrNameStart, i);
const nameEnd = base + i;
while (i < content.length && /\s/.test(content[i])) {
i++;
}
let hasValue = false;
let value = "";
let valueStart = -1;
let valueEnd = -1;
let quoteStart = -1;
let quoteEnd = -1;
let doubleQuoted = true;
if (content[i] === "=") {
i++;
while (i < content.length && /\s/.test(content[i])) {
i++;
}
const q = content[i];
if (q === '"' || q === "'") {
doubleQuoted = q === '"';
hasValue = true;
quoteStart = base + i;
i++;
const valueStartLocal = i;
while (i < content.length && content[i] !== q) {
i++;
}
valueStart = base + valueStartLocal;
valueEnd = base + i;
value = content.slice(valueStartLocal, i);
if (content[i] === q) {
i++;
quoteEnd = base + i;
}
} else {
// unquoted value - tolerate
const vs = i;
while (i < content.length && !/[\s>]/.test(content[i])) {
i++;
}
value = content.slice(vs, i);
hasValue = true;
valueStart = base + vs;
valueEnd = base + i;
quoteStart = valueStart;
quoteEnd = valueEnd;
}
}
attrs.push({
name: attrName,
value,
nameStart: base + attrNameStart,
nameEnd,
valueStart,
valueEnd,
quoteStart,
quoteEnd,
hasValue,
doubleQuoted,
});
}
return {
name,
selfClosing,
start: base - 1,
contentStart: base,
contentEnd: base + i,
end: base + i,
attrs,
};
}
export function parseXml(text: string): XmlDocument {
const lineMap = new LineMap(text);
const errors: XmlParseError[] = [];
const elements: XmlElement[] = [];
const stack: XmlElement[] = [];
let root: XmlElement | null = null;
let declarationEnd = 0;
let i = 0;
const n = text.length;
const err = (message: string, offset: number) => {
const pos = lineMap.positionAt(offset);
errors.push({ message, offset, line: pos.line, character: pos.character });
};
while (i < n) {
const lt = text.indexOf("<", i);
if (lt < 0) break;
if (lt > i && stack.length === 0 && errors.length === 0) {
// text before the root element - ignore unless it is non-whitespace
const between = text.slice(i, lt);
if (between.trim() !== "") {
err("Content is not allowed before the root element", i);
}
}
i = lt;
// comment
if (text.startsWith("<!--", i)) {
const close = text.indexOf("-->", i + 4);
if (close < 0) {
err("Unterminated comment", i);
break;
}
i = close + 3;
continue;
}
// CDATA
if (text.startsWith("<![CDATA[", i)) {
const close = text.indexOf("]]>", i + 9);
if (close < 0) {
err("Unterminated CDATA section", i);
break;
}
i = close + 3;
continue;
}
// DOCTYPE
if (text.startsWith("<!DOCTYPE", i) || text.startsWith("<!doctype", i)) {
const close = text.indexOf(">", i);
if (close < 0) {
err("Unterminated DOCTYPE", i);
break;
}
i = close + 1;
continue;
}
// processing instruction / declaration
if (text.startsWith("<?", i)) {
const close = text.indexOf("?>", i + 2);
if (close < 0) {
err("Unterminated processing instruction", i);
break;
}
if (i === 0 && /^<\?xml\s/i.test(text.slice(i, close + 2))) {
declarationEnd = close + 2;
}
i = close + 2;
continue;
}
// closing tag
if (text.startsWith("</", i)) {
const gt = text.indexOf(">", i + 2);
if (gt < 0) {
err("Unterminated closing tag", i);
break;
}
const name = text.slice(i + 2, gt).trim();
const top = stack[stack.length - 1];
if (!top) {
err(`Unexpected closing tag </${name}>`, i);
} else if (top.name !== name) {
err(`Mismatched closing tag: expected </${top.name}>, found </${name}>`, i);
// recover: find the matching element on the stack if possible
let idx = stack.length - 1;
while (idx >= 0 && stack[idx].name !== name) idx--;
if (idx >= 0) {
const closingCount = stack.length - 1 - idx;
for (let k = 0; k < closingCount; k++) {
const el = stack.pop()!;
el.end = gt + 1;
el.closeTagStart = i;
}
}
} else {
const el = stack.pop()!;
el.end = gt + 1;
el.closeTagStart = i;
}
i = gt + 1;
continue;
}
// opening tag
if (text[i + 1] === "!" || text[i + 1] === "?") {
err("Malformed markup", i);
i++;
continue;
}
const gt = findTagEnd(text, i + 1);
if (gt < 0) {
err("Unterminated start tag", i);
const content = text.slice(i + 1);
const raw = parseTag(content, i + 1);
if (raw.name) {
const el = buildElement(raw, stack.length);
elements.push(el);
root = root ?? el;
stack.push(el);
}
break;
}
const content = text.slice(i + 1, gt);
const raw = parseTag(content, i + 1);
raw.end = gt + 1;
const el = buildElement(raw, stack.length);
elements.push(el);
if (stack.length === 0) {
root = root ?? el;
} else {
const parent = stack[stack.length - 1];
parent.children.push(el);
el.parent = parent;
}
if (!raw.selfClosing) {
stack.push(el);
}
i = gt + 1;
}
if (stack.length > 0) {
for (const el of stack) {
const pos = lineMap.positionAt(el.start);
errors.push({
message: `Element <${el.name}> is never closed`,
offset: el.start,
line: pos.line,
character: pos.character,
});
el.end = n;
}
}
return { root, elements, errors, declarationEnd };
}
function findTagEnd(text: string, from: number): number {
let i = from;
let quote: string | null = null;
while (i < text.length) {
const c = text[i];
if (quote) {
if (c === quote) quote = null;
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === ">") {
return i;
}
i++;
}
return -1;
}
function buildElement(raw: RawTag, depth: number): XmlElement {
return {
name: raw.name,
attrs: raw.attrs,
children: [],
parent: null,
start: raw.start,
startTagEnd: raw.end,
end: raw.selfClosing ? raw.end : -1,
selfClosing: raw.selfClosing,
closeTagStart: -1,
depth,
};
}
/** Returns the innermost element whose span contains `offset`. */
export function findElementAt(doc: XmlDocument, offset: number): XmlElement | null {
let best: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
if (!best || el.depth > best.depth) {
best = el;
}
}
}
return best;
}
/** Finds an element by name that contains the offset (including its start tag). */
export function findOpenTagElementAt(doc: XmlDocument, offset: number): XmlElement | null {
const el = findElementAt(doc, offset);
if (!el) return null;
// When the cursor is inside the start tag itself, `el` is already the
// innermost candidate. If the cursor is before the element's start, use
// the parent.
if (offset >= el.start && offset <= el.startTagEnd) return el;
return el;
}
+86
View File
@@ -0,0 +1,86 @@
{
"version": 1,
"source": "OpenSAGE src/OpenSage.Game/Data/StreamFS/AssetType.cs",
"count": 79,
"types": {
"299416263": "AudioLod",
"315614191": "LocalBuildListMonitor",
"354222972": "GameLodPreset",
"376113229": "AudioFile",
"400896388": "CrowdResponse",
"439207783": "TheaterOfWarTemplate",
"504350110": "InGameUIPlayerPowerCommandSlots",
"530081230": "IntelDB",
"531798225": "StaticGameLod",
"534008255": "LargeGroupAudioMap",
"565855655": "ImageSequence",
"568797146": "Texture",
"607123156": "AIStrategicStateDefinition",
"608742960": "W3dAnimation",
"610186489": "InGameUIVoiceChatCommandSlots",
"680780553": "Environment",
"686292351": "FXParticleSystemTemplate",
"726253425": "Achievement",
"741706624": "MpGameRules",
"819131716": "RadiusCursorLibrary",
"866546168": "InGameUISettings",
"926814458": "AITargetingHeuristic",
"962203606": "InGameUILookAtCommandSlots",
"980180622": "ArmorTemplate",
"1345252658": "OnlineChatColors",
"1350608344": "MappableKey",
"1443425905": "AudioSettings",
"1447728787": "VideoEventList",
"1449288224": "PackedTextureImage",
"1482556238": "CampaignTemplate",
"1628705662": "InGameUIGroupSelectionCommandSlots",
"1641540160": "W3dHierarchy",
"1713477273": "PlayerPowerButtonTemplateStore",
"1874610847": "ExperienceLevelTemplate",
"1883691512": "MusicTrack",
"2007776008": "TargetingInTurretArcCompare",
"2070603733": "MiscAudio",
"2101756272": "LogicCommand",
"2178440954": "SpecialPowerTemplate",
"2219670431": "AudioEvent",
"2254974584": "FXList",
"2359666647": "TargetingDistanceCompare",
"2384988189": "MultiplayerColor",
"2421413379": "UnitOverlayIconSettings",
"2430161325": "Weather",
"2458866148": "InGameUIFixedElementHotKeySlotMap",
"2467477932": "OnDemandTexture",
"2486173485": "GameObject",
"2496977262": "WeaponTemplate",
"2525284163": "StanceTemplate",
"2525492603": "Mouse",
"2565744451": "InGameUISideBarCommandSlots",
"2745675575": "Multisound",
"2800139175": "HotKeySlot",
"2811124014": "InGameUIUnitAbilityCommandSlots",
"2812698028": "InGameUITacticalCommandSlots",
"2893598307": "AIBudgetStateDefinition",
"2901356964": "DynamicGameLod",
"2905958645": "DamageFX",
"3188107749": "TargetingCompareList",
"3266421346": "W3dMesh",
"3319822471": "AttributeModifier",
"3477794083": "MissionTemplate",
"3558134211": "DialogEvent",
"3587539190": "ArmyDefinition",
"3604279694": "AIPersonalityDefinition",
"3614134471": "UnitTypeIcon",
"3650896041": "PhaseEffect",
"3741098742": "DefaultHotKeys",
"3786401627": "UpgradeTemplate",
"3810008068": "W3dCollisionBox",
"3899542881": "ObjectCreationList",
"3928762264": "AmbientStream",
"3959844197": "LogicCommandSet",
"3971904488": "SkirmishOpeningMove",
"3972178387": "LocomotorTemplate",
"4042295058": "W3dContainer",
"4157475773": "ShadowMap",
"4262364347": "OnDemandTextureImage"
}
}
File diff suppressed because one or more lines are too long
+214
View File
@@ -0,0 +1,214 @@
import schemaModel from "./schema-model.json";
import assetTypes from "./asset-types.json";
export interface ChildInfo {
name: string;
type: string | null;
min: number;
max: number; // -1 = unbounded
doc: string;
}
export interface AttributeInfo {
name: string;
required: boolean;
default: string | null;
doc: string;
kind: string;
type: string | null;
refType: string | null;
/** True for reference-typed attributes whose simple type has no refType. */
isRef: boolean;
enumValues: string[];
allowsDefine: boolean;
isBoolean: boolean;
base: string | null;
}
export interface ComplexTypeInfo {
kind: "complex";
children: ChildInfo[];
attributes: AttributeInfo[];
base: string | null;
doc: string;
}
export interface SimpleTypeInfo {
kind: "simple";
base: string | null;
refType: string | null;
isRef: boolean;
enumValues: string[];
allowsDefine: boolean;
doc: string;
}
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
interface RawModel {
version: number;
rootXsd: string;
topLevelElements: string[];
elements: Record<string, { type: string | null; doc: string }>;
types: Record<string, TypeInfo>;
subTypesOf: Record<string, string[]>;
}
const model = schemaModel as unknown as RawModel;
/** Lowercase type name -> canonical (XSD) type name. */
const typeNameIndex = new Map<string, string>();
for (const name of Object.keys(model.types)) {
const lower = name.toLowerCase();
if (!typeNameIndex.has(lower)) typeNameIndex.set(lower, name);
}
/** Resolves a possibly-mis-cased type name to its canonical XSD spelling. */
export function canonicalTypeName(name: string | null): string | null {
if (!name) return null;
return typeNameIndex.get(name.toLowerCase()) ?? name;
}
/** element name -> type name, collected from every complex type's children. */
const elementToType = new Map<string, string>();
for (const type of Object.values(model.types)) {
if (type.kind !== "complex") continue;
for (const child of type.children) {
if (!elementToType.has(child.name)) {
elementToType.set(child.name, child.type ?? "");
}
}
}
for (const [name, info] of Object.entries(model.elements)) {
elementToType.set(name, info.type ?? "");
}
export const modelMeta = {
rootXsd: model.rootXsd,
topLevelElementCount: model.topLevelElements.length,
typeCount: Object.keys(model.types).length,
};
export function topLevelElements(): string[] {
return model.topLevelElements;
}
export function isTopLevelElement(name: string): boolean {
return model.topLevelElements.includes(name);
}
export function typeInfo(name: string): TypeInfo | undefined {
return model.types[name];
}
export function elementTypeName(name: string): string | null {
const t = elementToType.get(name);
return t ? t : null;
}
export function childrenOfElement(name: string): ChildInfo[] {
const type = elementTypeName(name);
if (!type) return [];
return childrenOfType(type);
}
export function childrenOfType(typeName: string | null): ChildInfo[] {
if (!typeName) return [];
const info = model.types[canonicalTypeName(typeName) ?? typeName];
return info && info.kind === "complex" ? info.children : [];
}
export function attributesOfElement(name: string): AttributeInfo[] {
const type = elementTypeName(name);
if (!type) return [];
return attributesOfType(type);
}
export function attributesOfType(typeName: string | null): AttributeInfo[] {
if (!typeName) return [];
const info = model.types[canonicalTypeName(typeName) ?? typeName];
return info && info.kind === "complex" ? info.attributes : [];
}
/**
* Returns the type of a child element inside a KNOWN parent type, or null
* when the parent type is unknown or the child is not declared there.
*/
export function childTypeOf(
parentTypeName: string | null,
childName: string,
): string | null {
if (!parentTypeName) return null;
const info = model.types[canonicalTypeName(parentTypeName) ?? parentTypeName];
if (info?.kind !== "complex") return null;
return info.children.find((c) => c.name === childName)?.type ?? null;
}
/**
* Context-aware element type resolution: prefers the child declaration inside
* the parent element's type, falling back to the global element map. Same
* element names used in different parents (e.g. <Weapon> under a weapon slot
* vs. a plain reference) therefore resolve to their contextually correct type.
*/
export function elementTypeIn(
parentElementName: string | null,
childName: string,
): string | null {
if (parentElementName) {
const parentType = elementTypeName(parentElementName);
const typed = childTypeOf(parentType, childName);
if (typed) return typed;
}
return elementTypeName(childName);
}
export function typeDoc(name: string): string {
const info = model.types[name];
return info?.doc ?? "";
}
/** The type itself plus all ancestors (nearest first). */
export function typeChain(name: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
let cur: string | null = canonicalTypeName(name);
while (cur && !seen.has(cur)) {
seen.add(cur);
out.push(cur);
const info: TypeInfo | undefined = model.types[cur];
cur = info && "base" in info && info.base ? canonicalTypeName(info.base) : null;
}
return out;
}
/**
* True when an asset of type `actualType` satisfies a reference to `refType`.
* Falls back to exact name matching; unknown types only match exactly.
*/
export function isAssignableTo(actualType: string, refType: string | null): boolean {
if (!refType) return true;
const actual = canonicalTypeName(actualType) ?? actualType;
const ref = canonicalTypeName(refType) ?? refType;
if (actual === ref) return true;
return typeChain(actual).includes(ref);
}
/** Maps a manifest TypeId hash to a type name, when known. */
export function assetTypeNameFromHash(hash: number): string | undefined {
return (assetTypes as { types: Record<string, string> }).types[hash];
}
export function assetTypeHashCount(): number {
return (assetTypes as { count: number }).count ?? 0;
}
/** Elements that are structurally relevant everywhere. */
export const STRUCTURAL_ELEMENTS = [
"AssetDeclaration",
"Includes",
"Include",
"Tags",
"Tag",
"Defines",
"Define",
];
+32
View File
@@ -0,0 +1,32 @@
import * as vscode from "vscode";
import { join } from "node:path";
export interface ExtensionSettings {
sdkPath: string;
indexSageXml: boolean;
reportUnresolvedReferences: "warning" | "information" | "none";
diagnoseUnknownElements: boolean;
definitionMode: "all" | "project-only";
additionalDataSearchPaths: string[];
builtmodsDirs: string[];
}
export function readSettings(): ExtensionSettings {
const cfg = vscode.workspace.getConfiguration("ra3modxml");
const sdkPath = cfg.get<string>("sdkPath", "C:\\Apps\\RA3-MODSDK-X");
return {
sdkPath,
indexSageXml: cfg.get<boolean>("indexSageXml", true),
reportUnresolvedReferences: cfg.get<string>(
"reportUnresolvedReferences",
"warning",
) as ExtensionSettings["reportUnresolvedReferences"],
diagnoseUnknownElements: cfg.get<boolean>("diagnoseUnknownElements", true),
definitionMode: cfg.get<string>(
"definitionMode",
"all",
) as ExtensionSettings["definitionMode"],
additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []),
builtmodsDirs: [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")],
};
}
+145
View File
@@ -0,0 +1,145 @@
import * as vscode from "vscode";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { CachedDirectoryWalker } from "./indexer/fileScanner";
import { ModIndexer } from "./indexer/indexer";
import type { ModIndex } from "./indexer/types";
import { readSettings, type ExtensionSettings } from "./settings";
const REBUILD_DEBOUNCE_MS = 1500;
export class ModWorkspace {
index: ModIndex | null = null;
indexer: ModIndexer | null = null;
projectRoot: string | null = null;
settings: ExtensionSettings;
private walker = new CachedDirectoryWalker();
private statusBar: vscode.StatusBarItem;
private rebuildTimer: ReturnType<typeof setTimeout> | null = null;
private building = false;
private dirty = false;
constructor(context: vscode.ExtensionContext) {
this.settings = readSettings();
this.statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
100,
);
this.statusBar.name = "RA3 Mod XML";
this.statusBar.command = "ra3modxml.openIndexReport";
context.subscriptions.push(this.statusBar);
}
isRa3Workspace(): boolean {
return this.projectRoot != null;
}
detectProjectRoot(): string | null {
const folders = vscode.workspace.workspaceFolders;
if (!folders?.length) return null;
for (const folder of folders) {
const found = findProjectRoot(folder.uri.fsPath);
if (found) return found;
}
return null;
}
async initialize(): Promise<void> {
this.projectRoot = this.detectProjectRoot();
if (!this.projectRoot) {
this.statusBar.hide();
return;
}
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
this.statusBar.show();
await this.rebuild();
}
scheduleRebuild(): void {
if (!this.projectRoot) return;
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
this.rebuildTimer = setTimeout(() => {
void this.rebuild();
}, REBUILD_DEBOUNCE_MS);
}
async rebuild(): Promise<void> {
if (!this.projectRoot) return;
if (this.building) {
this.dirty = true;
return;
}
this.building = true;
this.settings = readSettings();
try {
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
const indexer = new ModIndexer({
projectDir: this.projectRoot,
sdkDir: this.settings.sdkPath,
builtmodsDirs: this.settings.builtmodsDirs,
indexSageXml: this.settings.indexSageXml,
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
walker: this.walker,
});
const started = Date.now();
this.index = await indexer.build();
this.indexer = indexer;
const secs = ((Date.now() - started) / 1000).toFixed(1);
const s = this.index.stats;
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`;
this.statusBar.tooltip =
`${s.projectDir}\n` +
`${s.indexedFiles} files indexed (${secs}s)\n` +
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
} catch (err) {
this.index = null;
this.statusBar.text = "$(error) RA3 XML: indexing failed";
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
} finally {
this.building = false;
if (this.dirty) {
this.dirty = false;
void this.rebuild();
}
}
}
/** Parses the (possibly unsaved) in-memory text of the active document. */
async parseText(path: string, text: string) {
const { parseXml, LineMap } = await import("./language/xmlParser");
const parse = parseXml(text);
return {
file: { path, stat: null },
parse,
lineMap: new LineMap(text),
};
}
dispose(): void {
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
this.statusBar.dispose();
}
}
function findProjectRoot(startDir: string): string | null {
let dir = startDir;
// Guard against walking above reasonable roots.
for (let i = 0; i < 12; i++) {
try {
if (existsSync(join(dir, "Data", "Mod.xml"))) return dir;
if (existsSync(join(dir, "mod.babproj"))) return dir;
} catch {
return null;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function formatCount(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "RA3 Mod XML",
"scopeName": "source.ra3modxml",
"injectionSelector": "L:source.xml",
"patterns": [
{
"name": "constant.other.ra3.define",
"match": "\\$[A-Za-z_][A-Za-z0-9_]*"
},
{
"name": "keyword.other.ra3.inherit",
"match": "\\binheritFrom\\b"
},
{
"name": "keyword.other.ra3.joinaction",
"match": "xai:joinAction"
},
{
"name": "entity.name.tag.ra3.include",
"match": "</?(AssetDeclaration|Includes|Include|Tags|Tag|Defines|Define)\\b"
},
{
"name": "comment.line.ra3.todo",
"match": "(?i)(TODO|FIXME|HACK|XXX)(?=\\s|:|-|$)"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseVehicle" Side="Allies" />
</AssetDeclaration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="SageStaticMarker" />
</AssetDeclaration>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="VanillaTank" />
<WeaponTemplate id="VanillaCannon" />
</AssetDeclaration>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset"></AssetDeclaration>
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset"></AssetDeclaration>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="all" source="SageXml/Vanilla.xml" />
</Includes>
</AssetDeclaration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseVehicle" Side="Allies" />
</AssetDeclaration>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<HeadlightDraw2 id="FragLight">
<Model Name="frag.mdl" />
</HeadlightDraw2>
</AssetDeclaration>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<!-- Same id on two different asset types: navigation must filter by the
attribute's reference type. -->
<WeaponTemplate id="SharedThing" />
<GameObject id="SharedThing" />
<!-- Nested xi:include: content is inlined into the parent element. -->
<GameObject id="XiNestedGameObject">
<Draws>
<xi:include
href="DATA:Includes/Fragment.xml"
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)" />
<xi:include href="DATA:Missing/Nested.xml" />
</Draws>
</GameObject>
</AssetDeclaration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Texture id="TestTexture" File="test.dds" />
</AssetDeclaration>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Defines>
<Define name="TEST_HEALTH" value="100.0" />
</Defines>
<GameObject
id="TestTank"
inheritFrom="BaseVehicle"
CommandSet="TestTankCommandSet"
KindOf="SELECTABLE">
</GameObject>
<GameObject
id="TestTank"
Side="Allies">
</GameObject>
<Includes>
<Include type="instance" source="BaseVehicle.xml" />
</Includes>
</AssetDeclaration>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<WeaponTemplate id="TestTankCannon" AttackRange="100.0" />
<LogicCommandSet id="TestTankCommandSet" />
<Includes>
<Include type="all" source="Missing/File.xml" />
</Includes>
</AssetDeclaration>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Tags />
<Includes>
<Include type="reference" source="DATA:static.xml" />
<Include type="reference" source="DATA:global.xml" />
<Include type="reference" source="DATA:audio.xml" />
<Include type="all" source="Includes/Units.xml" />
<Include type="all" source="Includes/Weapons.xml" />
<Include type="all" source="Includes/Shared.xml" />
<Include type="all" source="Includes/Refs.xml" />
</Includes>
</AssetDeclaration>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<MpGameRules id="TheMpGameRules">
<SkirmishStartCash LoCash="10000" HiCash="50000" />
</MpGameRules>
</AssetDeclaration>
+89
View File
@@ -0,0 +1,89 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
buildSearchPaths,
resolveSource,
manifestPathForReference,
} from "../out/indexer/includeResolver.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
const sdk = join(root, "test", "fixtures", "fakesdk");
const paths = buildSearchPaths(sdk, project);
test("resolves relative includes against the current file directory", () => {
const current = join(project, "Data", "Includes");
const r = resolveSource("BaseVehicle.xml", current, paths);
assert.equal(r.path, join(project, "Data", "Includes", "BaseVehicle.xml"));
});
test("resolves DATA: includes through search paths", () => {
const r = resolveSource("DATA:static.xml", null, paths);
assert.equal(r.path, join(sdk, "static.xml"));
const r2 = resolveSource("DATA:SageXml/Vanilla.xml", null, paths);
assert.equal(r2.path, join(sdk, "SageXml", "Vanilla.xml"));
});
test("case-insensitive prefix", () => {
const r = resolveSource("data:static.xml", null, paths);
assert.equal(r.path, join(sdk, "static.xml"));
});
test("missing target returns null", () => {
const r = resolveSource("DATA:nope/nothere.xml", null, paths);
assert.equal(r.path, null);
assert.equal(r.prefix, "DATA");
});
test("search paths follow the BinaryAssetBuilder /data /art /audio order", () => {
// defaultscript.cs getIncludePaths():
// /data: ".;{modGranParent};{0}\Data;.\Mods;{1};.\SageXml"
// /art: ".;{modGranParent};{0}\Art1;{0}\Art;.\Mods;{1};.\Art"
// /audio: ".;{modGranParent};{0}\Audio1;{0}\Audio;.\Mods;{1};.\Audio"
// where "." = sdkDir, {0} = projectDir, {1} = modParentPath,
// {2} = modGranParent.
const modParent = dirname(project);
const granParent = dirname(modParent);
assert.deepEqual(paths.DATA, [
sdk,
granParent,
join(project, "Data"),
join(sdk, "Mods"),
modParent,
join(sdk, "SageXml"),
]);
assert.deepEqual(paths.ART, [
sdk,
granParent,
join(project, "Art1"),
join(project, "Art"),
join(sdk, "Mods"),
modParent,
join(sdk, "Art"),
]);
assert.deepEqual(paths.AUDIO, [
sdk,
granParent,
join(project, "Audio1"),
join(project, "Audio"),
join(sdk, "Mods"),
modParent,
join(sdk, "Audio"),
]);
});
test("DATA:Static.xml prefers the SDK root over SageXml", () => {
// Both the SDK root and SageXml contain a Static.xml; BAB order resolves to
// the SDK root placeholder first.
const r = resolveSource("DATA:Static.xml", null, paths);
assert.equal(r.path, join(sdk, "Static.xml"));
});
test("manifest mapping strips the prefix", () => {
const dirs = [join(sdk, "builtmods")];
assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest"));
assert.equal(manifestPathForReference("DATA:audio.xml", dirs), join(dirs[0], "audio.manifest"));
assert.equal(manifestPathForReference("DATA:missing.xml", dirs), null);
});
+72
View File
@@ -0,0 +1,72 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
const sdk = join(root, "test", "fixtures", "fakesdk");
async function buildIndex() {
const indexer = new ModIndexer({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
return indexer.build();
}
test("indexes assets, defines, streams and include errors", async () => {
const idx = await buildIndex();
assert.equal(idx.stats.streams, 2); // static + global
assert.ok(idx.assetsById.has("testtank"));
assert.ok(idx.assetsById.has("testtankcannon"));
assert.ok(idx.assetsById.has("testtankcommandset"));
assert.ok(idx.assetsById.has("testtexture"));
assert.ok(idx.assetsById.has("thempgamerules"));
assert.ok(idx.assetsById.has("vanillatank"), "vanilla data via reference include fallback");
assert.ok(idx.assetsById.has("basevehicle"));
const baseVehicle = idx.assetsById.get("basevehicle");
assert.ok(baseVehicle.some((d) => d.viaInstance), "instance-included asset marked");
const defs = idx.defines.get("test_health");
assert.equal(defs?.[0].value, "100.0");
const missing = idx.diagnostics.find((d) => d.code === "include-not-found");
assert.ok(missing, "missing include reported");
assert.match(missing.message, /Missing\/File\.xml/);
// Nested xi:include: existing target is walked, missing target is reported.
assert.ok(
idx.diagnostics.some((d) => /Missing\/Nested\.xml/.test(d.message)),
"nested missing xi:include reported",
);
assert.ok(
idx.files.has(
join(project, "Data", "Includes", "Fragment.xml").toLowerCase(),
),
"nested xi:include target indexed",
);
assert.ok(
idx.assetsById.has("fraglight"),
"nested xi:include target assets available",
);
const staticStream = idx.streams.find((s) => s.name === "static");
assert.ok(staticStream.files.size >= 4);
});
test("provides include source candidates", async () => {
const idx = await buildIndex();
const xml = idx.sourceCandidates.filter((c) => c.source.endsWith(".xml"));
assert.ok(xml.length >= 4);
assert.ok(xml.some((c) => c.source === "Includes/Units.xml"));
assert.ok(xml.some((c) => c.source === "DATA:static.xml"));
});
+61
View File
@@ -0,0 +1,61 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseManifest } from "../out/indexer/manifestParser.js";
function u32(v) {
const b = Buffer.alloc(4);
b.writeUInt32LE(v >>> 0);
return b;
}
function u16(v) {
const b = Buffer.alloc(2);
b.writeUInt16LE(v >>> 0);
return b;
}
test("parses a minimal version 5 manifest", () => {
const name = Buffer.from("VanillaTank\0", "ascii");
const src = Buffer.from("DATA:globaldata/armor.xml\0", "ascii");
const parts = [
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true (one byte each)
u16(5), // version
u32(0x1234), // streamChecksum
u32(0), // allTypesHash
u32(1), // assetCount
u32(0), // totalInstanceDataSize
u32(0), // maxInstanceChunkSize
u32(0), // maxRelocationChunkSize
u32(0), // maxImportsChunkSize
u32(0), // assetReferenceBufferSize
u32(0), // referencedManifestNameBufferSize
u32(name.length), // assetNameBufferSize
u32(src.length), // sourceFileNameBufferSize
// one asset entry (version 5 = 44 bytes, no tokenized flag)
u32(0x942fff2d), // typeId (GameObject)
u32(0), // instanceId
u32(0), // typeHash
u32(0), // instanceHash
u32(0), // assetReferenceOffset
u32(0), // assetReferenceCount
u32(0), // nameOffset
u32(0), // sourceFileNameOffset
u32(0), // instanceDataSize
u32(0), // relocationDataSize
u32(0), // importsDataSize
name,
src,
];
const info = parseManifest(new Uint8Array(Buffer.concat(parts)));
assert.equal(info.version, 5);
assert.equal(info.assetCount, 1);
assert.equal(info.assets.length, 1);
assert.equal(info.assets[0].name, "VanillaTank");
assert.equal(info.assets[0].typeName, "GameObject");
assert.equal(info.assets[0].sourceFileName, "DATA:globaldata/armor.xml");
});
test("reports an error for garbage input", () => {
const info = parseManifest(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]));
assert.ok(info.error);
assert.equal(info.assets.length, 0);
});
+83
View File
@@ -0,0 +1,83 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { deriveAssetId, deriveAssetType } from "../out/indexer/manifestParser.js";
import { isAssignableTo } from "../out/model/schemaModel.js";
import {
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../out/indexer/refs.js";
test("deriveAssetType falls back to the TypeName:Id prefix", () => {
assert.equal(deriveAssetType(null, "PlayerTemplate:Allies"), "PlayerTemplate");
assert.equal(deriveAssetType(null, "AudioFile:A06_Loop"), "AudioFile");
assert.equal(deriveAssetType("GameObject", "GameObject:X"), "GameObject");
assert.equal(deriveAssetType(null, "NoColonHere"), null);
assert.equal(deriveAssetType(null, ":LeadingColon"), null);
});
test("deriveAssetId uses the last colon segment", () => {
// Art assets carry an extra subtype segment: Type:SubType:FileName.
assert.equal(
deriveAssetId("W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN"),
"AUANTIVEHICLEVEHICLETECH1_SKN",
);
assert.equal(deriveAssetId("PlayerTemplate:Allies"), "Allies");
assert.equal(deriveAssetId("AudioFile:A06_Loop"), "A06_Loop");
assert.equal(deriveAssetId("Texture:Texture:AUAntiVehicleVehicleTech1"), "AUAntiVehicleVehicleTech1");
assert.equal(deriveAssetId("NoColon"), "NoColon");
});
test("case-normalized type matching follows the XSD inheritance chain", () => {
// Manifest hashes use "W3d*" casing while the XSD types are "W3D*"; the
// canonical lookup must bridge the two and respect the real chain.
assert.ok(
isAssignableTo("W3dContainer", "BaseRenderAssetType"),
"W3dContainer is a render asset",
);
assert.ok(
isAssignableTo("W3DMesh", "BaseRenderAssetType"),
"W3DMesh is a render asset",
);
assert.ok(
!isAssignableTo("W3dHierarchy", "BaseRenderAssetType"),
"W3dHierarchy is NOT a render asset (per XSD)",
);
});
test("manifest-derived PlayerTemplate ids satisfy Side references", () => {
// Simulate a manifest asset whose TypeId hash is unknown but whose name
// carries the type prefix (exactly what global.manifest does for
// PlayerTemplate:Allies).
const idx = {
assetsById: new Map([
[
"allies",
[
{
type: "PlayerTemplate",
id: "Allies",
file: "global.manifest",
line: 0,
origin: "manifest",
},
],
],
]),
assets: new Map([["PlayerTemplate", new Map([["allies", []]])]]),
projectDir: ".",
sdkDir: ".",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
assert.ok(isAssignableTo("PlayerTemplate", "PlayerTemplate"));
assert.equal(isReferenceAttributeOfType("GameObject", "Side"), true);
const targets = resolveReferenceTargetsForType(idx, "GameObject", "Side", "Allies");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "PlayerTemplate");
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
import { isReferenceAttribute, resolveReferenceTargets } from "../out/indexer/refs.js";
import * as model from "../out/model/schemaModel.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
const sdk = join(root, "test", "fixtures", "fakesdk");
async function buildIndex() {
const indexer = new ModIndexer({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
return indexer.build();
}
test("definition resolution filters by the attribute's ref type", async () => {
const idx = await buildIndex();
const defs = idx.assetsById.get("sharedthing");
assert.equal(defs.length, 2);
const types = defs.map((d) => d.type).sort();
assert.deepEqual(types, ["GameObject", "WeaponTemplate"]);
// WeaponName="SharedThing" on a WeaponTemplateRef attribute must only resolve
// to the WeaponTemplate definition.
const targets = resolveReferenceTargets(idx, "FireWeaponNugget", "WeaponName", "SharedThing");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "WeaponTemplate");
assert.match(targets[0].def.file, /Refs\.xml/);
// A non-reference attribute must never resolve anything.
const none = resolveReferenceTargets(idx, "FireWeaponNugget", "EditorName", "SharedThing");
assert.equal(none.length, 0);
// inheritFrom filters to types assignable to the element itself.
const inherit = resolveReferenceTargets(idx, "GameObject", "inheritFrom", "SharedThing");
assert.equal(inherit.length, 1);
assert.equal(inherit[0].def.type, "GameObject");
});
test("the model knows a real WeaponTemplateRef attribute pair", () => {
const attrs = model.attributesOfElement("FireWeaponNugget");
const weapon = attrs.find((a) => a.name === "WeaponName");
assert.equal(weapon?.refType, "WeaponTemplate");
});
test("isReferenceAttribute distinguishes references from enums/paths", () => {
// Include/@type and Include/@source must never be treated as references.
assert.equal(isReferenceAttribute("Include", "type"), false);
assert.equal(isReferenceAttribute("Include", "source"), false);
// Typed references and inheritFrom are references.
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
});
test("untyped references (isRef without refType) resolve to any declared id", () => {
// LocomotorSet/@Locomotor has type AssetReference: xas:isRef="true" with
// no refType. It must be treated as a reference and match the id regardless
// of the target asset type.
const idx = {
assetsById: new Map([
[
"alliedantivehiclevehicletech1locomotor",
[
{
type: "LocomotorTemplate",
id: "AlliedAntiVehicleVehicleTech1Locomotor",
file: "Locomotor.xml",
line: 5,
origin: "project",
},
],
],
]),
assets: new Map(),
projectDir: ".",
sdkDir: ".",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
assert.equal(isReferenceAttribute("LocomotorSet", "Locomotor"), true);
const targets = resolveReferenceTargets(idx, "LocomotorSet", "Locomotor", "AlliedAntiVehicleVehicleTech1Locomotor");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "LocomotorTemplate");
// A non-reference attribute still returns nothing.
assert.equal(resolveReferenceTargets(idx, "LocomotorSet", "EditorName", "X").length, 0);
});
+35
View File
@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as model from "../out/model/schemaModel.js";
test("top-level elements include core asset types", () => {
for (const name of ["GameObject", "WeaponTemplate", "Texture", "LogicCommandSet", "SpecialPowerTemplate"]) {
assert.ok(model.isTopLevelElement(name), name);
}
});
test("GameObject has expected attributes", () => {
const attrs = model.attributesOfElement("GameObject");
const id = attrs.find((a) => a.name === "id");
assert.ok(id?.required);
const cmd = attrs.find((a) => a.name === "CommandSet");
assert.equal(cmd?.refType, "LogicCommandSet");
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
});
test("Include has reference/instance/all enum", () => {
const attrs = model.attributesOfElement("Include");
const type = attrs.find((a) => a.name === "type");
assert.deepEqual(type?.enumValues, ["reference", "instance", "all"]);
});
test("type assignability follows inheritance", () => {
assert.ok(model.isAssignableTo("GameObject", "BaseInheritableAsset"));
assert.ok(model.isAssignableTo("GameObject", "GameObject"));
assert.ok(!model.isAssignableTo("WeaponTemplate", "GameObject"));
});
test("asset type hashes resolve", () => {
assert.equal(model.assetTypeNameFromHash(0x942fff2d), "GameObject");
assert.equal(model.assetTypeNameFromHash(0x166b084d), "AudioFile");
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml } from "../out/language/xmlParser.js";
import { resolveElementType } from "../out/language/typeContext.js";
import * as model from "../out/model/schemaModel.js";
test("context-aware type resolution follows the parent chain", () => {
const text = `<?xml version="1.0"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Tank">
<BehaviorModules>
<WeaponSetUpdate>
<WeaponSlotTurret ID="1">
<Weapon Ordering="PRIMARY_WEAPON" />
</WeaponSlotTurret>
</WeaponSetUpdate>
</BehaviorModules>
</GameObject>
</AssetDeclaration>`;
const doc = parseXml(text);
const weapon = doc.elements.find((e) => e.name === "Weapon");
assert.ok(weapon, "Weapon element parsed");
const weaponType = resolveElementType(weapon);
assert.equal(weaponType, "WeaponSlot_WeaponData");
const attrs = model.attributesOfType(weaponType);
assert.ok(attrs.some((a) => a.name === "Ordering"), "Ordering attribute present");
// The same element name in a non-slot context resolves differently
// (e.g. a plain WeaponRef) and must not leak the slot attributes.
const slot = doc.elements.find((e) => e.name === "WeaponSlotTurret");
assert.equal(resolveElementType(slot), "WeaponSlot_Turret");
});
test("model childTypeOf primitives", () => {
assert.equal(model.childTypeOf("WeaponSetUpdateModuleData", "WeaponSlotTurret"), "WeaponSlot_Turret");
assert.equal(model.childTypeOf("WeaponSlot_Turret", "Weapon"), "WeaponSlot_WeaponData");
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
assert.equal(model.childTypeOf(null, "Weapon"), null);
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml, findElementAt } from "../out/language/xmlParser.js";
test("parses elements, attributes and positions", () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`;
const doc = parseXml(text);
assert.equal(doc.errors.length, 0);
assert.equal(doc.root.name, "AssetDeclaration");
const go = doc.root.children[0];
assert.equal(go.name, "GameObject");
assert.equal(go.selfClosing, true);
assert.equal(go.attrs.length, 2);
assert.equal(go.attrs[0].name, "id");
assert.equal(go.attrs[0].value, "X");
assert.equal(go.attrs[1].value, "A B");
const idAttr = go.attrs[0];
assert.equal(text.slice(idAttr.valueStart, idAttr.valueEnd), "X");
});
test("detects mismatched and unclosed tags", () => {
const doc = parseXml("<A><B></A>");
assert.ok(doc.errors.length >= 1);
assert.match(doc.errors[0].message, /Mismatched|never closed/);
});
test("handles CRLF and comments", () => {
const text = "<?xml version=\"1.0\"?>\r\n<!-- hello -->\r\n<AssetDeclaration>\r\n</AssetDeclaration>\r\n";
const doc = parseXml(text);
assert.equal(doc.errors.length, 0);
assert.equal(doc.root.name, "AssetDeclaration");
});
test("findElementAt returns innermost element", () => {
const text = `<A><B id="1"><C/></B></A>`;
const doc = parseXml(text);
const c = doc.elements.find((e) => e.name === "C");
const at = findElementAt(doc, c.start + 1);
assert.equal(at.name, "C");
});
test("tolerates partial input while typing", () => {
const text = `<AssetDeclaration>\n\t<GameObject id="TestTank" Com`;
const doc = parseXml(text);
assert.ok(doc.errors.length >= 1); // unclosed
assert.equal(doc.elements.length, 2);
});
+58
View File
@@ -0,0 +1,58 @@
// Extracts the SAGE AssetType hash -> type name mapping from OpenSAGE's
// AssetType.cs into src/model/asset-types.json.
//
// Usage:
// node tools/extract-asset-types.mjs [path-to-AssetType.cs]
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const defaultSource = resolve(
__dirname,
"..",
"OpenSAGE",
"src",
"OpenSage.Game",
"Data",
"StreamFS",
"AssetType.cs",
);
const sourcePath = process.argv[2] ?? defaultSource;
const text = readFileSync(sourcePath, "utf8");
// Match lines like:
// GameObject = 0x942FFF2D,
// W3dAnimation = 0x2448AE30,
const typeRegex = /^\s*(\w+)\s*=\s*0x([0-9A-Fa-f]{8})\s*,/gm;
const types = new Map();
let match;
while ((match = typeRegex.exec(text)) !== null) {
const name = match[1];
const hash = match[2].toUpperCase();
const numeric = parseInt(hash, 16);
if (types.has(numeric)) {
console.warn(`duplicate hash 0x${hash}: ${types.get(numeric)} vs ${name}`);
}
types.set(numeric, name);
}
if (types.size === 0) {
console.error(`No AssetType entries found in ${sourcePath}`);
process.exit(1);
}
const output = {
version: 1,
source: "OpenSAGE src/OpenSage.Game/Data/StreamFS/AssetType.cs",
count: types.size,
types: Object.fromEntries(
[...types.entries()].map(([hash, name]) => [hash, name]),
),
};
const outPath = resolve(__dirname, "..", "src", "model", "asset-types.json");
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(output, null, 1));
console.log(`Wrote ${types.size} asset types to ${outPath}`);
+430
View File
@@ -0,0 +1,430 @@
// Generates a compact JSON schema model from the RA3 Mod SDK XSD files.
//
// Usage:
// node tools/xsd-to-model.mjs [path-to-CnC3Types.xsd] [output.json]
//
// The model is used at runtime by the extension for completions, hover
// documentation and diagnostics. It is regenerated by developers, not by
// end users.
import { XMLParser } from "fast-xml-parser";
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
statSync,
} from "node:fs";
import { dirname, resolve, basename, relative } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT =
process.env.RA3_SDK_XSD ??
"C:\\Apps\\RA3-MODSDK-X\\Schemas\\xsd\\CnC3Types.xsd";
const rootXsdPath = resolve(process.argv[2] ?? DEFAULT_ROOT);
const outPath = resolve(
process.argv[3] ?? resolve(__dirname, "..", "src", "model", "schema-model.json"),
);
if (!existsSync(rootXsdPath)) {
console.error(`Root XSD not found: ${rootXsdPath}`);
console.error("Pass a path or set RA3_SDK_XSD.");
process.exit(1);
}
// ── XML parsing ──────────────────────────────────────────────────────
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
removeNSPrefix: true,
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
ignoreDeclaration: true,
});
const asArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]);
// ── Registry ─────────────────────────────────────────────────────────
const loadedFiles = new Map(); // absolute path -> parsed document
const complexTypes = new Map(); // name -> raw node
const simpleTypes = new Map(); // name -> raw node
const globalElements = new Map(); // name -> {type, doc}
function loadXsd(absPath, seen = new Set()) {
const key = absPath.toLowerCase();
if (loadedFiles.has(key)) return loadedFiles.get(key);
if (seen.has(key)) return null; // include cycle
seen.add(key);
let text;
try {
text = readFileSync(absPath, "utf8");
} catch (err) {
console.warn(` ! cannot read ${absPath}: ${err.message}`);
return null;
}
const doc = parser.parse(text);
loadedFiles.set(key, doc);
const schema = doc?.schema ?? doc?.["xs:schema"] ?? {};
for (const inc of asArray(schema.include)) {
const loc = inc?.["@_schemaLocation"];
if (!loc) continue;
loadXsd(resolve(dirname(absPath), loc), seen);
}
for (const ct of asArray(schema.complexType)) {
const name = ct?.["@_name"];
if (name) complexTypes.set(name, ct);
}
for (const st of asArray(schema.simpleType)) {
const name = st?.["@_name"];
if (name) simpleTypes.set(name, st);
}
for (const el of asArray(schema.element)) {
const name = el?.["@_name"];
if (!name) continue;
if (el.complexType) {
// Inline complexType for a global element (e.g. AssetDeclaration).
const keyName = `@global:${name}`;
complexTypes.set(keyName, el.complexType);
globalElements.set(name, { type: keyName, doc: docOf(el) });
} else if (el["@_type"]) {
globalElements.set(name, { type: el["@_type"], doc: docOf(el) });
}
}
return doc;
}
function docOf(node) {
const doc = node?.annotation?.documentation;
if (doc == null) return "";
return String(doc).trim();
}
// ── Attribute / simple type helpers ──────────────────────────────────
function normalizeTypeName(raw) {
if (!raw) return null;
return String(raw).replace(/^xs:/, "").trim();
}
const BUILTIN = new Set([
"string",
"boolean",
"integer",
"int",
"long",
"short",
"byte",
"unsignedInt",
"unsignedShort",
"unsignedByte",
"decimal",
"float",
"double",
"anyURI",
"date",
"dateTime",
"duration",
"token",
"NMTOKEN",
"Name",
"ID",
]);
function patternAllowsDefine(pattern) {
return /\(=\[_a-zA-Z]/.test(pattern) || /=\$/.test(pattern);
}
function enumOfSimpleType(node) {
const restriction = node?.restriction;
if (!restriction) return [];
const enums = asArray(restriction.enumeration).map((e) => e?.["@_value"]);
return enums.filter((v) => v != null);
}
function patternOfSimpleType(node) {
const restriction = node?.restriction;
if (!restriction) return null;
const pats = asArray(restriction.pattern).map((p) => p?.["@_value"]);
return pats.length ? pats : null;
}
/**
* Resolve an attribute type reference to a normalized descriptor.
* Returns { kind: "builtin"|"simple"|"complex", name, refType, enumValues,
* allowsDefine, isBoolean, base, doc }
*/
function resolveTypeDescriptor(typeName, memo = new Map()) {
const name = normalizeTypeName(typeName);
if (name == null) return null;
if (memo.has(name)) return memo.get(name);
if (BUILTIN.has(name)) {
const desc = {
kind: "builtin",
name,
refType: null,
enumValues: [],
allowsDefine: false,
isBoolean: name === "boolean",
base: null,
doc: "",
};
memo.set(name, desc);
return desc;
}
const st = simpleTypes.get(name);
if (st) {
memo.set(name, null); // guard against cycles
const base = normalizeTypeName(st?.restriction?.["@_base"]);
const baseDesc = base ? resolveTypeDescriptor(base, memo) : null;
const enumValues = enumOfSimpleType(st);
const patterns = patternOfSimpleType(st);
const refType =
st?.["@_refType"] ??
(baseDesc?.refType ?? null);
const desc = {
kind: "simple",
name,
refType: normalizeTypeName(refType) || baseDesc?.refType || null,
enumValues: enumValues.length ? enumValues : baseDesc?.enumValues ?? [],
allowsDefine:
(patterns ? patterns.some(patternAllowsDefine) : false) ||
baseDesc?.allowsDefine ||
false,
isRef:
st?.["@_isRef"] === "true" ||
st?.["@_isWeakRef"] === "true" ||
baseDesc?.isRef ||
false,
isBoolean: name === "SageBool" || baseDesc?.isBoolean || false,
base,
doc: docOf(st),
};
memo.set(name, desc);
return desc;
}
if (complexTypes.has(name) || name.startsWith("@")) {
const desc = {
kind: "complex",
name,
refType: null,
enumValues: [],
allowsDefine: false,
isRef: false,
isBoolean: false,
base: null,
doc: "",
};
memo.set(name, desc);
return desc;
}
const desc = {
kind: "unknown",
name,
refType: null,
enumValues: [],
allowsDefine: false,
isRef: false,
isBoolean: false,
base: null,
doc: "",
};
memo.set(name, desc);
return desc;
}
// ── Complex type expansion ───────────────────────────────────────────
function collectChildren(node, out = []) {
if (!node || typeof node !== "object") return out;
for (const el of asArray(node.element)) {
const name = el?.["@_name"];
if (!name) continue;
const child = {
name,
type: normalizeTypeName(el["@_type"]),
min: parseInt(el["@_minOccurs"] ?? "1", 10),
max: el["@_maxOccurs"] === "unbounded" ? -1 : parseInt(el["@_maxOccurs"] ?? "1", 10),
doc: docOf(el),
};
if (el.complexType) {
const inlineKey = `@inline:${name}`;
if (!complexTypes.has(inlineKey)) complexTypes.set(inlineKey, el.complexType);
child.type = inlineKey;
}
out.push(child);
}
for (const key of ["sequence", "choice", "all"]) {
for (const container of asArray(node[key])) {
collectChildren(container, out);
}
}
return out;
}
function collectAttributes(node, out = []) {
if (!node) return out;
for (const attr of asArray(node.attribute)) {
const name = attr?.["@_name"];
if (!name) continue;
const use = attr?.["@_use"];
let desc = null;
let inlineEnum = [];
if (attr["@_type"]) {
desc = resolveTypeDescriptor(attr["@_type"]);
} else if (attr.simpleType) {
inlineEnum = enumOfSimpleType(attr.simpleType);
const pats = patternOfSimpleType(attr.simpleType);
desc = {
kind: "simple",
name: `@attr:${name}`,
refType: attr.simpleType?.["@_refType"] ?? null,
enumValues: inlineEnum,
allowsDefine: pats ? pats.some(patternAllowsDefine) : false,
isBoolean: false,
base: normalizeTypeName(attr.simpleType?.restriction?.["@_base"]),
doc: docOf(attr.simpleType),
};
}
const entry = {
name,
required: use === "required",
default: attr["@_Default"] ?? attr["@_default"] ?? null,
doc: docOf(attr),
kind: desc?.kind ?? "unknown",
type: desc?.name ?? null,
refType: normalizeTypeName(desc?.refType) ?? null,
enumValues: desc?.enumValues ?? inlineEnum ?? [],
allowsDefine: desc?.allowsDefine ?? false,
isRef: desc?.isRef ?? false,
isBoolean: desc?.isBoolean ?? false,
base: normalizeTypeName(desc?.base) ?? null,
};
out.push(entry);
}
return out;
}
const expandedTypes = new Map(); // typeName -> {children, attributes, base, doc}
function expandComplexType(name, chain = []) {
if (expandedTypes.has(name)) return expandedTypes.get(name);
if (chain.includes(name)) {
// inheritance cycle (should not happen) - bail out
const empty = { children: [], attributes: [], base: null, doc: "" };
expandedTypes.set(name, empty);
return empty;
}
const node = complexTypes.get(name);
if (!node) return null;
const extension = node?.complexContent?.extension;
const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]);
const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;
const ownChildren = collectChildren(extension ?? node);
const ownAttributes = collectAttributes(extension ?? node);
const children = [...(base?.children ?? []), ...ownChildren];
const attributes = [...(base?.attributes ?? [])];
for (const attr of ownAttributes) {
const idx = attributes.findIndex((a) => a.name === attr.name);
if (idx >= 0) attributes[idx] = attr;
else attributes.push(attr);
}
const result = {
children,
attributes,
base: baseName,
doc: docOf(node),
};
expandedTypes.set(name, result);
return result;
}
// ── Build model ──────────────────────────────────────────────────────
console.log(`Loading XSD tree from ${rootXsdPath} ...`);
const start = Date.now();
loadXsd(rootXsdPath);
console.log(
`Loaded ${loadedFiles.size} XSD files in ${((Date.now() - start) / 1000).toFixed(1)}s` +
` (${complexTypes.size} complexTypes, ${simpleTypes.size} simpleTypes, ${globalElements.size} global elements)`,
);
// Top-level elements come from the AssetDeclaration inline choice plus any
// global elements registered directly.
const assetDecl = globalElements.get("AssetDeclaration");
const topLevelElements = new Set();
if (assetDecl) {
const declType = expandComplexType(assetDecl.type);
for (const child of declType?.children ?? []) {
topLevelElements.add(child.name);
}
}
// Serialize resolved complex types.
const typesOut = {};
for (const name of complexTypes.keys()) {
const resolved = expandComplexType(name);
if (resolved) {
typesOut[name] = {
kind: "complex",
...resolved,
};
}
}
for (const [name, node] of simpleTypes) {
const desc = resolveTypeDescriptor(name);
typesOut[name] = {
kind: "simple",
base: desc?.base ?? normalizeTypeName(node?.restriction?.["@_base"]) ?? null,
refType: desc?.refType ?? node?.["@_refType"] ?? null,
isRef: node?.["@_isRef"] === "true" || desc?.refType != null,
enumValues: desc?.enumValues ?? [],
allowsDefine: desc?.allowsDefine ?? false,
doc: docOf(node),
};
}
// Inheritance map: subtype -> [base names], used to match refType against
// concrete asset types (a ref to BaseAudioEventInfo should also match
// AudioEvent).
const subTypesOf = {};
for (const [name, node] of complexTypes) {
const base = normalizeTypeName(node?.complexContent?.extension?.["@_base"]);
if (base) {
(subTypesOf[name] ??= []).push(base);
}
}
const model = {
version: 2,
rootXsd: basename(rootXsdPath),
targetNamespace: "uri:ea.com:eala:asset",
topLevelElements: [...topLevelElements].sort(),
elements: Object.fromEntries(
[...globalElements.entries()].map(([name, info]) => [
name,
{ type: normalizeTypeName(info.type), doc: info.doc },
]),
),
types: typesOut,
subTypesOf,
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(model));
const sizeKb = Math.round(statSync(outPath).size / 1024);
console.log(
`Wrote ${outPath} (${sizeKb} KB, ${topLevelElements.size} top-level elements, ` +
`${Object.keys(typesOut).length} types)`,
);
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": [
"ES2022"
],
"outDir": "out",
"rootDir": "src",
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": [
"src/**/*.ts"
]
}