Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e055216c | ||
|
|
21c2275c00 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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-01;xi:include 部分已实施于第十二轮)
|
||||
## 六、include 展开设计备忘(2026-08-01;xi: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 编译产物,无文本内容 |
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user