2 Commits
Author SHA1 Message Date
lanyi b5e055216c 0.1.22 2026-08-11 16:23:58 +02:00
lanyi 21c2275c00 fixed manifest references with same id 2026-08-11 15:17:30 +02:00
13 changed files with 599 additions and 33 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 0.1.22 — 2026-08-11
### Fixed
- Manifest assets that share the same id under different types are now all kept in the by-id index. Previously the first same-id entry (e.g. `W3DHierarchy:AUMCV_HOVER`) could shadow later definitions (e.g. `W3DContainer:AUMCV_HOVER`), causing `Model@Name` and other `BaseRenderAssetType` references to be reported as unresolved even though the asset existed in `Static.manifest`.
- `xi:include` without an `xpointer` now splices the target document's root element itself (XInclude semantics), so fragments like `GenericCelestialBuildingSuicide.xml` keep their module wrapper (`CreateObjectDie`) instead of only inserting its children.
- Fragment files (documents whose root is not `AssetDeclaration`) no longer trigger standalone-document diagnostics: top-level `missing-id`, duplicate-id, unresolved-reference and undefined-define checks are skipped, unknown wrapper roots are not reported as unknown elements, and a known fragment root still validates its subtree's elements/attributes.
### Added
- Missing `xi:include` targets now surface in the Problems panel as `include-not-found` warnings for the edited document (previously only tracked in indexer diagnostics).
## 0.1.21 — 2026-08-11
### Added
+145
View File
@@ -1813,3 +1813,148 @@ manifest 候选就会被劫持到 mod 文件。
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
> 已包含对应 SDK 目录,将来若有源码可直接复用。
---
## 二十九、问题分析(2026-08-11):manifest 同名不同类型资产被 `assetsById` 去重丢弃
### 现象
Corona `Data\Allied\Units\AlliedMCV.xml` 的
`ScriptedModelDraw → ModelConditionState → Model Name="AUMCV_Hover"`
报 unresolved-reference
```xml
<ScriptedModelDraw id="ModuleTag_Draw_Hover" OkToChangeModelColor="true">
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
<Model Name="AUMCV_Hover" />
</ModelConditionState>
</ScriptedModelDraw>
```
提示为“没有类型为 `BaseRenderAssetType` 的定义(其他类型存在同名 id)”,
但 `Static.manifest` 中确实存在 `W3DContainer:AUMCV_HOVER`。
### 根因
`src/indexer/indexer.ts` 的 `addAsset()` 在维护两个索引时用了同一套去重:
- `assets``类型 -> id -> 定义`,按 `(file, line)` 去重;
- `assetsById``id -> 所有类型定义`,也按 `(file, line)` 去重。
XML 定义的行号各不相同,所以 `(file, line)` 足够;但 manifest 资产入库时
`line` 固定为 0,于是同一个 manifest 里 id 相同、类型不同的多个资产会被
当成同一条定义,只保留最先出现的类型。
`Static.manifest` 中 `AUMCV_HOVER` 的实际顺序是:
```text
W3DHierarchy:AUMCV_HOVER
W3DAnimation:AUMCV_HOVER
W3DContainer:AUMCV_HOVER
```
`W3DContainer` 因此被 `W3DHierarchy` 挤掉。`Model@Name` 的 `refType` 是
`BaseRenderAssetType``W3DHierarchy` 按 XSD 继承链不是渲染资产,所以
`assetsById` 里“有同名 id”但“没有匹配类型”,正好产生上述提示。
### 为什么以前没暴露 / 不是回归
第三轮修复的 `AUAntiVehicleVehicleTech1_SKN` 在 static.manifest 里只有一个
同名定义(`W3DContainer`),没有类型竞争,因此当时测不到该分支。git blame
显示 `addAsset` 的 `(file, line)` 去重从首个提交就存在,所以这是潜在缺陷被
新数据形态首次触发,不是近期改动造成的回归。
### 影响面(真实 manifest 扫描)
对 `Static / Global / Audio` 三个 manifest 模拟当前入库逻辑:
| 指标 | 数值 |
|---|---:|
| 同名 id 跨类型的 ID | 1318 |
| 被丢弃的类型定义 | 1470 |
| `W3DContainer` / `W3DMesh` 被丢弃的 id | 412 |
`Audio.manifest` 无此类碰撞。受影响的不止诊断和 hover:
`resolveReferenceTargetsForType`、语义 FAR / CodeLens 引用计数、未类型化补全
都经 `assetsById` 查找,因此 manifest 中的模型引用普遍可能误报或漏计。
### 修复
`assetsById` 是“按 id 汇总所有类型定义”的索引,去重身份必须包含类型:
1. `src/indexer/indexer.ts` 的 `addAsset()``assets` 与 `assetsById` 的去重
都改为 `(type, file, line)`
2. `src/indexer/localScope.ts` 的 `addAsset()`:同样的去重修正,避免局部
overlay 未来遇到同构数据时重复踩坑。
`mergeLocalAndGlobalDefs`、`assetDefKey` 本来已按 `(type, id, file, line)`
区分定义,修复后三处语义一致。
### 测试(新增 1 个集成测试,全量 198 个通过)
`test/indexer.test.mjs` 新增自包含用例:
- 用最小 version-5 manifest 构造 `W3DHierarchy / W3DAnimation / W3DContainer`
三个同 id 资产,顺序刻意让渲染类型排在最后;
- 再构造 `Texture:ABAirfield` 在前、`W3DContainer:ABAIRFIELD` 在后的常见形态;
- 断言 `assetsById` 保留全部类型;
- 断言 `Model@Name` 经 `resolveReferenceTargetsForType` 命中 `W3DContainer`
- 断言反向引用索引把该引用记到 `W3DContainer` 名下。
### 文档同步
`docs/plan.md` 的 manifest 建模小节补充:`assetsById` 必须保留同 id 的不同
类型定义,去重身份为 `(type, file, line)`。
---
## 二十八、问题分析(2026-08-11):xi:include 无 xpointer 语义与片段文件诊断(P0)
### 现象
`Data/Includes/GenericCelestialBuildingSuicide.xml` 这类被 `xi:include` 引用的
片段文件在独立打开时被报一串错误:`DieMuxData` 报 `missing-id`(“顶层资产需要
id”),wrapper 根不在 XSD 里的文件报 `unknown-element`,引用在完整索引下能解析
前还会报未解析引用。
### 根因
1. **无 `xpointer` 的展开语义错误**`expandDocument` 把目标 `root.children`
拼进父节点。按 XInclude 语义(也是 Corona 的实际用法),没有 `xpointer` 时应
整体包含目标文档的根元素。`GenericCelestialBuildingSuicide.xml` 的根
`CreateObjectDie` 本身就是要放进 GameObject 的模块;旧实现会丢掉它,只把
`DieMuxData` 拼进去。
2. **诊断层把片段当完整文档**:`isTopLevel` 假定根一定是 `AssetDeclaration`
于是片段根的子元素被当成顶层资产要求 id;未知 wrapper 根也被当成未知元素。
3. **引用/define 与上下文耦合**:片段里的引用可能由 include 者(或 include 者
的 include 链)提供,独立打开片段时无法可靠判定。
### 修复(P0,不猜测外部上下文)
1. `logicalTree.expandDocument`:无 `xpointer` 时 `handleChild(parse.root)`
整体包含目标根元素;有 `xpointer` 时保持 `/n:Name/child::*` 语义。
2. `diagnostics` 片段模式:根 localName 不是 `AssetDeclaration` 即为片段。
- 一律跳过顶层 `missing-id` / 跨文件重复 id、未解析引用、未定义 `$DEFINE`
- 根是已知 XSD 元素时(如 `CreateObjectDie`),根自身提供类型上下文,整棵子树
的未知元素 / 未知属性仍正常校验;
- 根不在 XSD 中(wrapper/container,如 `CommonArmorDraws`)时,只报 XML 语法
与片段内部 `xi:include` / `<Include>` 目标缺失,其余检查延后到上下文诊断。
3. 新增 `checkXiInclude``xi:include` 目标缺失在 Problems 中上报
`include-not-found`(此前只在 indexer 内部诊断)。
### 测试(202 → 202 全绿)
- `localScope.test.mjs`:无 `xpointer` 的 `xi:include` 把目标根元素
`CreateObjectDie` 整体拼入 `Behaviors``DieMuxData` 仍挂在它下面;
- `contentFeatures.test.mjs`:片段已知根不再报 `missing-id` / 未解析引用,但子树
未知属性仍报;未知 wrapper 根不报元素/属性,片段内部缺失 `xi:include` 仍报;
完整 `AssetDeclaration` 文档的顶层 id 检查不受影响。
### 边界与后续
- **P1 上下文诊断**indexer 增加“反向 include 表”(`xi:include` 目标 →
include 者列表),打开片段时用 include 者的逻辑树做真实上下文校验,再恢复引用 /
define / 子元素结构检查。多上下文取并集去重。
- `<Include type="all|instance|reference">` 与 `xi:include` 语义不同:前者的目标
是完整 `AssetDeclaration`,不进入片段模式;后者才允许片段文件。
+11 -8
View File
@@ -1,6 +1,6 @@
# 调研结论与实施计划(已按最新代码同步更新)
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-10)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)等。
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-11)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)、`assetsById` 保留同 id 的不同类型 manifest 定义等。
## 一、调研结论(带证据)
@@ -140,7 +140,7 @@ test/
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name``Hierarchy``Mesh`
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
跨重建复用(详见设计决策 14)。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER``W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
@@ -464,7 +464,7 @@ test/
展开(第十二轮,见第六节);顶层 `<Include type="all">``inheritFrom` +
`xai:joinAction` 的深合并仍未实现,后续如需要“当前文档视角的全量合并诊断”再继续。
## 六、include 展开设计备忘(2026-08-01xi:include 部分已实施于第十二轮)
## 六、include 展开设计备忘(2026-08-01xi:include 部分已实施于第十二轮,无 xpointer 语义与片段诊断见第二十八轮
> 目的:集中记录 include 处理相关的现状、结论与设计,下次遇到 include 问题时从这里继续,
> 并在实施后把结果回写本节。
@@ -477,7 +477,8 @@ test/
| `reference` → builtmods manifest 解析 / 缺失回退占位 XML | 已实现 |
| 嵌套 `xi:include`(任意层级):目标可索引、缺失报 `include-not-found`、Ctrl+点击跳转、`href` hover 解析目标 | 已实现(第二轮 + 第五轮) |
| `xi:include` 及其属性不参与 XSD 校验(外来命名空间守卫 `isXsdElementName` / `isXsdAttributeName` | 已实现(第五轮) |
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);顶层 `<Include type="all">` 仍不展开 |
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);**无 `xpointer` 时整体包含目标根元素**(第二十八轮修正);顶层 `<Include type="all">` 仍不展开 |
| 片段文件(根非 `AssetDeclaration`)的诊断 | 已实现 P0(第二十八轮):跳过顶层 id/重复/引用/define 检查;根为已知 XSD 元素时仍校验子树;根未知时只报语法与 include 缺失 |
### 2. 已确认的方向
@@ -488,9 +489,11 @@ BAB`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
- **不要**把 include 目标展开成文本再整体重新解析:源码偏移会断裂,诊断 / 跳转 / hover /
补全全部无法映射回原始文件。
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点`xpointer`
子集挂进父元素,节点保留各自的源文件与原始偏移(来源追溯)。后续分析跑在逻辑树上,
范围映射按节点 `sourceFile` 回到对应文件的 lineMap。
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点挂进父元素——
`xpointer` 时取 `/n:Name/child::*` 选中 children,无 `xpointer` 时按 XInclude 语义
整体包含目标根元素(RA3 片段如 `CreateObjectDie` 依赖这一行为);节点保留各自的源文件
与原始偏移(来源追溯)。后续分析跑在逻辑树上,范围映射按节点 `sourceFile` 回到对应
文件的 lineMap。
- 现有 `parseXml` 已记录标签 / 属性 / 值的起止偏移,`XmlElement` 结构可直接复用;拼接时
用浅拷贝节点壳并重建 parent 链,避免破坏目标文件缓存树自身的 parent 指针。
@@ -498,7 +501,7 @@ BAB`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
| 构造 | 拼入逻辑树 | 理由 |
|---|---|---|
| `xi:include` | ✅ | 内容并入父元素(HeadlightDraw2 场景) |
| `xi:include` | ✅ | `xpointer`:选中容器 children;无 `xpointer`:整体包含目标根元素(CreateObjectDie / TechUpgradeReceiver 等片段场景) |
| EA `<Include type="all">` | ✅ | BAB“内容合并”,等价于复制进来 |
| `type="instance"` | ❌ | 只影响编译可见性;拼树会把 BaseVehicle 的顶层资产错误塞进当前文档 |
| `type="reference"` | ❌ | manifest 编译产物,无文本内容 |
+1
View File
@@ -66,6 +66,7 @@
" (based on a partial index)": " (based on a partial index)",
" (index incomplete — may be a false positive)": " (index incomplete — may be a false positive)",
"Include target not found: {0}": "Include target not found: {0}",
"xi:include target not found: {0}": "xi:include target not found: {0}",
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
"**Top-level asset element**": "**Top-level asset element**",
"Attributes: {0} · Children: {1}": "Attributes: {0} · Children: {1}",
+1
View File
@@ -66,6 +66,7 @@
" (based on a partial index)": "(基于部分索引)",
" (index incomplete — may be a false positive)": "(索引不完整——可能是误报)",
"Include target not found: {0}": "找不到 Include 目标:{0}",
"xi:include target not found: {0}": "找不到 xi:include 目标:{0}",
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 元素(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。",
"**Top-level asset element**": "**顶层资产元素**",
"Attributes: {0} · Children: {1}": "属性:{0} · 子元素:{1}",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml",
"displayName": "%ra3modxml.displayName%",
"description": "%ra3modxml.description%",
"version": "0.1.21",
"version": "0.1.22",
"publisher": "lanyi",
"license": "SEE LICENSE IN LICENSE",
"repository": {
+57 -3
View File
@@ -139,6 +139,17 @@ export class Ra3Diagnostics {
): void {
const settings = this.ws.settings;
const fileDuplicates = new Map<string, { line: number }>();
// A file whose root is not AssetDeclaration is an xi:include fragment
// (e.g. Data/Includes/GenericCelestialBuildingSuicide.xml). It is not a
// standalone RA3 document: top-level id / duplicate checks do not apply,
// and references/defines can only be resolved in the includer's context.
// When the fragment root itself is a known XSD element (e.g.
// CreateObjectDie), the root supplies the type context for its whole
// subtree, so element/attribute validation is still reliable.
const rootName = root ? localName(root.name) : "";
const isFragment = rootName !== "AssetDeclaration";
const validateTree =
!isFragment || (root !== null && model.elementTypeName(rootName) !== null);
for (const el of doc.elements) {
// Only report diagnostics for nodes that belong to the document being
@@ -155,7 +166,7 @@ export class Ra3Diagnostics {
const range = tagRange(document, el);
// Top-level assets must have an id.
if (isTopLevel) {
if (!isFragment && isTopLevel) {
const idAttr = el.attrs.find((a) => a.name === "id");
if (!idAttr || !idAttr.value) {
diags.push(
@@ -206,7 +217,7 @@ export class Ra3Diagnostics {
const isXsdElement = model.isXsdElementName(el.name);
// Unknown element.
if (settings.diagnoseUnknownElements && isXsdElement) {
if (settings.diagnoseUnknownElements && isXsdElement && validateTree) {
const knownType = model.elementTypeName(local);
if (!knownType) {
diags.push(
@@ -221,7 +232,7 @@ export class Ra3Diagnostics {
}
// Attributes.
if (isXsdElement) {
if (isXsdElement && validateTree) {
const elType = resolveElementType(el);
const knownAttrs = model.attributesOfType(elType);
const knownNames = new Set(knownAttrs.map((a) => a.name));
@@ -247,6 +258,10 @@ export class Ra3Diagnostics {
}
if (!attr.hasValue) continue;
// References and $DEFINE constants inside fragments depend on the
// includer's context; don't report them until P1 resolves the real
// include sites.
if (!isFragment) {
this.checkValueReferences(
elType,
attr.name,
@@ -258,16 +273,55 @@ export class Ra3Diagnostics {
provisional,
);
}
}
if (!isFragment) {
this.checkContentReferences(el, elType, document, idx, diags, provisional);
}
}
// Include-specific checks.
if (local === "Include") {
this.checkInclude(el, document, idx, diags);
} else if (local === "include" && el.name.toLowerCase().startsWith("xi:")) {
this.checkXiInclude(el, document, idx, diags);
}
}
}
private checkXiInclude(
el: XmlElement,
document: vscode.TextDocument,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
): void {
const hrefAttr = el.attrs.find((a) => a.name === "href");
if (!hrefAttr?.hasValue) return;
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths(document);
if (!searchPaths) return;
const resolved = resolveSource(
hrefAttr.value,
dirname(document.uri.fsPath),
searchPaths,
);
if (resolved.path) return;
if (/^(DATA|ART|AUDIO):/i.test(hrefAttr.value.trim())) {
if (this.sdkUnusable()) return;
}
diags.push(
this.diag(
new vscode.Range(
document.positionAt(hrefAttr.valueStart),
document.positionAt(hrefAttr.valueEnd),
),
t("xi:include target not found: {0}", hrefAttr.value),
vscode.DiagnosticSeverity.Warning,
"include-not-found",
),
);
}
private checkCrossFileDuplicate(
type: string,
id: string,
+20 -2
View File
@@ -1014,14 +1014,32 @@ export class ModIndexer {
}
const arr = byId.get(idKey);
if (arr) {
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
if (
arr.some(
(a) =>
a.type === def.type &&
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;
if (
all.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
all.push(def);
} else {
this.assetsById.set(idKey, [def]);
+20 -2
View File
@@ -244,14 +244,32 @@ class OverlayBuilder {
}
const arr = byId.get(idKey);
if (arr) {
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
if (
arr.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.overlay.assetsById.get(idKey);
if (all) {
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
if (
all.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
all.push(def);
} else {
this.overlay.assetsById.set(idKey, [def]);
+19 -5
View File
@@ -169,13 +169,27 @@ async function expandXi(
if (!target?.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
const selected = xpointer
? findXPointerContainer(target.parse, xpointer)?.children ?? []
: target.parse.root.children;
for (const sel of selected) {
if (xpointer) {
const container = findXPointerContainer(target.parse, xpointer);
if (!container) return;
for (const sel of container.children) {
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
}
} else {
// XInclude semantics: without an xpointer the whole target document is
// included, i.e. its root element replaces the <xi:include> node.
// RA3 fragments such as GenericCelestialBuildingSuicide.xml rely on this
// to splice the module element itself (CreateObjectDie) into the parent.
await handleChild(
target.parse.root,
logicalParent,
resolved,
depth + 1,
ctx,
elements,
stack,
);
}
} finally {
stack.delete(key);
}
+86
View File
@@ -452,3 +452,89 @@ test("diagnostics report unresolved typed content references only", async () =>
"untyped WeakReference content is not diagnosed as a global ref",
);
});
test("fragment diagnostics skip document-level checks but keep subtree validation", async () => {
const text =
`<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X">\n` +
` <DieMuxData DeathTypo="SUICIDED"/>\n` +
`</CreateObjectDie>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("missing-id"),
"fragment children are not treated as top-level assets",
);
assert.ok(
!codes.some((c) => c.startsWith("unresolved-reference")),
"fragment references are deferred to the includer context",
);
assert.ok(
codes.includes("unknown-attribute"),
"a known fragment root still validates its subtree attributes",
);
});
test("fragment diagnostics ignore unknown wrapper roots and still report missing xi:include", async () => {
const text =
`<CommonArmorDraws xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">\n` +
` <ScriptedModelDraw id="M" Bogus="x"/>\n` +
` <xi:include href="MissingTarget.xml"/>\n` +
`</CommonArmorDraws>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-element"),
"wrapper roots are not validated as standalone documents",
);
assert.ok(
!codes.includes("unknown-attribute"),
"unknown wrapper roots do not trigger subtree attribute guessing",
);
assert.ok(
codes.includes("include-not-found"),
"missing xi:include targets inside fragments are still reported",
);
});
test("full documents still require ids on top-level assets", async () => {
const text = `<AssetDeclaration>\n <GameObject/>\n</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
codes.includes("missing-id"),
"AssetDeclaration documents keep top-level id checks",
);
});
+166
View File
@@ -12,6 +12,7 @@ import {
IndexRecordsCache,
} from "../out/indexer/caches.js";
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
import { assetDefKey } from "../out/indexer/referenceIndex.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
@@ -29,6 +30,75 @@ async function buildIndex() {
return indexer.build();
}
function u32(value) {
const b = Buffer.alloc(4);
b.writeUInt32LE(value >>> 0);
return b;
}
function u16(value) {
const b = Buffer.alloc(2);
b.writeUInt16LE(value >>> 0);
return b;
}
/** Minimal version-5 manifest with one asset entry per supplied descriptor. */
function minimalManifestV5(assets) {
const nameParts = [];
const sourceParts = [];
let nameOffset = 0;
let sourceOffset = 0;
const entries = assets.map((asset) => {
const name = Buffer.from(`${asset.name}\0`, "ascii");
const source = Buffer.from(`${asset.source ?? ""}\0`, "ascii");
const entry = {
typeId: asset.typeId,
nameOffset,
sourceFileNameOffset: sourceOffset,
};
nameParts.push(name);
sourceParts.push(source);
nameOffset += name.length;
sourceOffset += source.length;
return entry;
});
const names = Buffer.concat(nameParts);
const sources = Buffer.concat(sourceParts);
const parts = [
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true
u16(5), // version
u32(0), // streamChecksum
u32(0), // allTypesHash
u32(assets.length), // assetCount
u32(0), // totalInstanceDataSize
u32(0), // maxInstanceChunkSize
u32(0), // maxRelocationChunkSize
u32(0), // maxImportsChunkSize
u32(0), // assetReferenceBufferSize
u32(0), // referencedManifestNameBufferSize
u32(names.length), // assetNameBufferSize
u32(sources.length), // sourceFileNameBufferSize
];
for (const entry of entries) {
parts.push(
u32(entry.typeId),
u32(0), // instanceId
u32(0), // typeHash
u32(0), // instanceHash
u32(0), // assetReferenceOffset
u32(0), // assetReferenceCount
u32(entry.nameOffset),
u32(entry.sourceFileNameOffset),
u32(0), // instanceDataSize
u32(0), // relocationDataSize
u32(0), // importsDataSize
);
}
parts.push(names, sources);
return Buffer.concat(parts);
}
test("indexes assets, defines, streams and include errors", async () => {
const idx = await buildIndex();
@@ -133,6 +203,102 @@ test("w3x files appear in Include source completion candidates", async () => {
);
});
test("manifest assets sharing an id keep every type in assetsById", async () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-manifest-multitype-"));
const projectDir = join(tmp, "project");
const sdkDir = join(tmp, "sdk");
const builtmodsDir = join(sdkDir, "builtmods");
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
fs.mkdirSync(builtmodsDir, { recursive: true });
fs.writeFileSync(join(sdkDir, "Static.xml"), "<AssetDeclaration/>");
fs.writeFileSync(
join(projectDir, "Data", "Mod.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="reference" source="DATA:static.xml" />
</Includes>
<GameObject id="AlliedMCV">
<Draws>
<ScriptedModelDraw id="ModuleTag_Draw_Hover">
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
<Model Name="AUMCV_Hover" />
</ModelConditionState>
</ScriptedModelDraw>
</Draws>
</GameObject>
</AssetDeclaration>`,
);
fs.writeFileSync(
join(builtmodsDir, "static.manifest"),
minimalManifestV5([
{
typeId: 0x11111111,
name: "W3DHierarchy:AUMCV_HOVER",
source: "ART:aumcv_hover.w3x",
},
{
typeId: 0x22222222,
name: "W3DAnimation:AUMCV_HOVER",
source: "ART:aumcv_hover.w3x",
},
{
typeId: 0x33333333,
name: "W3DContainer:AUMCV_HOVER",
source: "ART:aumcv_hover.w3x",
},
{
typeId: 0x44444444,
name: "Texture:ABAirfield",
source: "ART:abairfield.tga",
},
{
typeId: 0x55555555,
name: "W3DContainer:ABAIRFIELD",
source: "ART:abairfield.w3x",
},
]),
);
const indexer = new ModIndexer({
projectDir,
sdkDir,
builtmodsDirs: [builtmodsDir],
indexSageXml: false,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
const idx = await indexer.build();
// The reported AUMCV_HOVER shape: Hierarchy/Animation precede the
// W3DContainer, so the by-id index must not drop the render asset.
const hover = idx.assetsById.get("aumcv_hover");
assert.ok(hover?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
assert.ok(hover?.some((d) => d.type === "W3DHierarchy"), "W3DHierarchy retained");
assert.ok(hover?.some((d) => d.type === "W3DAnimation"), "W3DAnimation retained");
const targets = resolveReferenceTargetsForType(
idx,
"ScriptedModelDrawModel",
"Name",
"AUMCV_Hover",
);
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "W3DContainer");
const container = hover.find((d) => d.type === "W3DContainer");
const sites = idx.references.get(assetDefKey(container));
assert.ok(
sites?.some((s) => s.kind === "attr" && /Mod\.xml$/.test(s.file)),
"Model reference is attributed to the W3DContainer definition",
);
// Common Texture-first shape must also keep the render definition.
const airfield = idx.assetsById.get("abairfield");
assert.ok(airfield?.some((d) => d.type === "Texture"), "Texture retained");
assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
});
test("build publishes an immutable XML phase before art scanning", async () => {
let phaseA;
const indexer = new ModIndexer({
+48
View File
@@ -99,6 +99,54 @@ test("logical xi:include expansion gives included modules their Draws context",
assert.ok(localIds.includes("ModuleTag_Headlight"));
});
test("xi:include without xpointer splices the target root element itself", async (t) => {
const tmp = await mkdtemp(join(tmpdir(), "ra3-local-noxpointer-"));
t.after(() => rm(tmp, { recursive: true, force: true }));
const dataDir = join(tmp, "Data");
const includesDir = join(dataDir, "Includes");
await mkdir(includesDir, { recursive: true });
const mainPath = join(dataDir, "Main.xml");
const fragmentPath = join(includesDir, "Fragment.xml");
await writeFile(
fragmentPath,
'<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X"><DieMuxData DeathTypes="SUICIDED"/></CreateObjectDie>',
"utf8",
);
await writeFile(
mainPath,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">' +
'<GameObject id="G"><Behaviors><xi:include href="DATA:Includes/Fragment.xml"/></Behaviors></GameObject>' +
"</AssetDeclaration>",
"utf8",
);
const searchPaths = buildSearchPaths(sdk, tmp);
const text = await readFile(mainPath, "utf8");
const scope = await buildDocumentScope(mainPath, text, 1, {
projectDir: tmp,
sdkDir: sdk,
searchPaths,
readRecords: readParsed,
readDom: readParsed,
});
const behaviors = scope.expanded.elements.find((e) => e.name === "Behaviors");
assert.ok(behaviors, "Behaviors exists");
assert.equal(behaviors.children.length, 1);
const module = behaviors.children[0];
assert.equal(module.name, "CreateObjectDie");
assert.equal(
module.attrs.find((a) => a.name === "id")?.value,
"ModuleTag_X",
);
assert.match(module.sourceFile, /Fragment\.xml$/i);
const dieMux = scope.expanded.elements.find((e) => e.name === "DieMuxData");
assert.ok(dieMux, "DieMuxData is expanded");
assert.equal(
dieMux.parent,
module,
"DieMuxData stays inside the included CreateObjectDie module",
);
});
test("local overlay wins over a global definition with the same id", async () => {
const scope = await makeScope();
const global = {